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,553 @@
1
+ """Revenue Tree Analysis Module.
2
+
3
+ This module implements a Revenue Tree analysis for retail businesses. The Revenue Tree
4
+ is a hierarchical breakdown of factors contributing to overall revenue, allowing for
5
+ detailed analysis of sales performance and identification of areas for improvement.
6
+
7
+ Key Components of the Revenue Tree:
8
+
9
+ 1. Revenue: The top-level metric, calculated as Customers * Revenue per Customer.
10
+
11
+ 2. Revenue per Customer: Average revenue generated per customer, calculated as:
12
+ Orders per Customer * Average Order Value.
13
+
14
+ 3. Orders per Customer: Average number of orders placed by each customer.
15
+
16
+ 4. Average Order Value: Average monetary value of each order, calculated as:
17
+ Items per Order * Price per Item.
18
+
19
+ 5. Items per Order: Average number of items in each order.
20
+
21
+ 6. Price per Item: Average price of each item sold.
22
+
23
+ This module can be used to create, update, and analyze Revenue Tree data structures
24
+ for retail businesses, helping to identify key drivers of revenue changes and
25
+ inform strategic decision-making.
26
+ """
27
+
28
+ import ibis
29
+ import numpy as np
30
+ import pandas as pd
31
+ from matplotlib.axes import Axes
32
+
33
+ from openretailscience.options import ColumnHelper, get_option
34
+ from openretailscience.plots.styles import graph_utils as gu
35
+ from openretailscience.plots.tree_diagram import DetailedTreeNode, TreeGrid
36
+ from openretailscience.plugin import plugin_manager
37
+
38
+
39
+ @plugin_manager.extensible
40
+ def calc_tree_kpis(
41
+ df: pd.DataFrame,
42
+ p1_index: list[bool] | pd.Series,
43
+ p2_index: list[bool] | pd.Series,
44
+ ) -> pd.DataFrame:
45
+ """Calculate various key performance indicators (KPIs) for tree analysis.
46
+
47
+ Args:
48
+ df (pd.DataFrame): Input DataFrame containing relevant data.
49
+ p1_index (list[bool] | pd.Series): Boolean index for period 1.
50
+ p2_index (list[bool] | pd.Series): Boolean index for period 2.
51
+
52
+ Returns:
53
+ pd.DataFrame: A DataFrame with calculated KPI values, including differences
54
+ and percentage differences between periods.
55
+ """
56
+ cols = ColumnHelper()
57
+ required_cols = [cols.agg.customer_id, cols.agg.transaction_id, cols.agg.unit_spend]
58
+
59
+ if cols.agg.unit_qty in df.columns:
60
+ required_cols.append(cols.agg.unit_qty)
61
+
62
+ df = df[required_cols].copy()
63
+ df_cols = df.columns
64
+
65
+ if cols.agg.unit_qty in df_cols:
66
+ df[cols.calc.units_per_trans] = df[cols.agg.unit_qty] / df[cols.agg.transaction_id]
67
+ df[cols.calc.price_per_unit] = df[cols.agg.unit_spend] / df[cols.agg.unit_qty]
68
+
69
+ df[cols.calc.spend_per_cust] = df[cols.agg.unit_spend] / df[cols.agg.customer_id]
70
+ df[cols.calc.spend_per_trans] = df[cols.agg.unit_spend] / df[cols.agg.transaction_id]
71
+ df[cols.calc.trans_per_cust] = df[cols.agg.transaction_id] / df[cols.agg.customer_id]
72
+
73
+ p1_df = df[p1_index]
74
+ p1_df.columns = [col + "_" + get_option("column.suffix.period_1") for col in p1_df.columns]
75
+ p2_df = df[p2_index]
76
+ p2_df.columns = [col + "_" + get_option("column.suffix.period_2") for col in p2_df.columns]
77
+
78
+ # When df only contains two periods than the indexes should be dropped for proper concatenation
79
+ period_count = 2
80
+ if len(df.index) == period_count:
81
+ p1_df = p1_df.reset_index(drop=True)
82
+ p2_df = p2_df.reset_index(drop=True)
83
+
84
+ # fillna with 0 to handle cases when one time period isn't present
85
+ df = pd.concat([p1_df, p2_df], axis=1).fillna(0)
86
+
87
+ for col in [
88
+ cols.agg.customer_id,
89
+ cols.agg.transaction_id,
90
+ cols.agg.unit_spend,
91
+ cols.calc.spend_per_trans,
92
+ cols.calc.trans_per_cust,
93
+ cols.calc.spend_per_cust,
94
+ ]:
95
+ # Difference calculations
96
+ df[col + "_" + get_option("column.suffix.difference")] = (
97
+ df[col + "_" + get_option("column.suffix.period_2")] - df[col + "_" + get_option("column.suffix.period_1")]
98
+ )
99
+
100
+ # Percentage change calculations
101
+ p1_col = df[col + "_" + get_option("column.suffix.period_1")]
102
+ df[col + "_" + get_option("column.suffix.percent_difference")] = df[
103
+ col + "_" + get_option("column.suffix.difference")
104
+ ] / p1_col.replace(0, np.nan)
105
+
106
+ # Calculate price elasticity
107
+ if cols.agg.unit_qty in df_cols:
108
+ qty_avg = ((df[cols.agg.unit_qty_p2] + df[cols.agg.unit_qty_p1]) / 2).replace(0, np.nan)
109
+ qty_pct_change = (df[cols.agg.unit_qty_p2] - df[cols.agg.unit_qty_p1]) / qty_avg
110
+
111
+ price_avg = ((df[cols.calc.price_per_unit_p2] + df[cols.calc.price_per_unit_p1]) / 2).replace(0, np.nan)
112
+ price_pct_change = (df[cols.calc.price_per_unit_p2] - df[cols.calc.price_per_unit_p1]) / price_avg
113
+
114
+ df[cols.calc.price_elasticity] = qty_pct_change / price_pct_change.replace(0, np.nan)
115
+
116
+ # Calculate frequency elasticity
117
+ freq_avg = ((df[cols.calc.trans_per_cust_p2] + df[cols.calc.trans_per_cust_p1]) / 2).replace(0, np.nan)
118
+ freq_pct_change = (df[cols.calc.trans_per_cust_p2] - df[cols.calc.trans_per_cust_p1]) / freq_avg
119
+
120
+ spend_avg = ((df[cols.calc.spend_per_cust_p2] + df[cols.calc.spend_per_cust_p1]) / 2).replace(0, np.nan)
121
+ spend_pct_change = (df[cols.calc.spend_per_cust_p2] - df[cols.calc.spend_per_cust_p1]) / spend_avg
122
+
123
+ df[cols.calc.frequency_elasticity] = freq_pct_change / spend_pct_change.replace(0, np.nan)
124
+
125
+ # Contribution calculations
126
+ df[cols.agg.customer_id_contrib] = (
127
+ df[cols.agg.unit_spend_p2]
128
+ - (df[cols.agg.customer_id_p1] * df[cols.calc.spend_per_cust_p2])
129
+ - ((df[cols.agg.customer_id_diff] * df[cols.calc.spend_per_cust_diff]) / 2)
130
+ )
131
+ df[cols.calc.spend_per_cust_contrib] = (
132
+ df[cols.agg.unit_spend_p2]
133
+ - (df[cols.calc.spend_per_cust_p1] * df[cols.agg.customer_id_p2])
134
+ - ((df[cols.agg.customer_id_diff] * df[cols.calc.spend_per_cust_diff]) / 2)
135
+ )
136
+
137
+ df[cols.calc.trans_per_cust_contrib] = (
138
+ (
139
+ df[cols.calc.spend_per_cust_p2]
140
+ - (df[cols.calc.trans_per_cust_p1] * df[cols.calc.spend_per_trans_p2])
141
+ - ((df[cols.calc.trans_per_cust_diff] * df[cols.calc.spend_per_trans_diff]) / 2)
142
+ )
143
+ * df[cols.agg.customer_id_p2]
144
+ ) - ((df[cols.agg.customer_id_diff] * df[cols.calc.spend_per_cust_diff]) / 4)
145
+
146
+ df[cols.calc.spend_per_trans_contrib] = (
147
+ (
148
+ df[cols.calc.spend_per_cust_p2]
149
+ - (df[cols.calc.spend_per_trans_p1] * df[cols.calc.trans_per_cust_p2])
150
+ - ((df[cols.calc.trans_per_cust_diff] * df[cols.calc.spend_per_trans_diff]) / 2)
151
+ )
152
+ * df[cols.agg.customer_id_p2]
153
+ ) - ((df[cols.agg.customer_id_diff] * df[cols.calc.spend_per_cust_diff]) / 4)
154
+
155
+ if cols.agg.unit_qty in df_cols:
156
+ # Difference calculations
157
+ for col in [
158
+ cols.agg.unit_qty,
159
+ cols.calc.units_per_trans,
160
+ cols.calc.price_per_unit,
161
+ ]:
162
+ df[col + "_" + get_option("column.suffix.difference")] = (
163
+ df[col + "_" + get_option("column.suffix.period_2")]
164
+ - df[col + "_" + get_option("column.suffix.period_1")]
165
+ )
166
+
167
+ for col in [
168
+ cols.agg.unit_qty,
169
+ cols.calc.units_per_trans,
170
+ cols.calc.price_per_unit,
171
+ ]:
172
+ p1_col = df[col + "_" + get_option("column.suffix.period_1")]
173
+ df[col + "_" + get_option("column.suffix.percent_difference")] = df[
174
+ col + "_" + get_option("column.suffix.difference")
175
+ ] / p1_col.replace(0, np.nan)
176
+
177
+ df[cols.calc.price_per_unit_contrib] = (
178
+ (
179
+ (
180
+ df[cols.calc.spend_per_trans_p2]
181
+ - (df[cols.calc.price_per_unit_p1] * df[cols.calc.units_per_trans_p2])
182
+ - ((df[cols.calc.units_per_trans_diff] * df[cols.calc.price_per_unit_diff]) / 2)
183
+ )
184
+ * df[cols.calc.trans_per_cust_p2]
185
+ )
186
+ - ((df[cols.calc.trans_per_cust_diff] * df[cols.calc.spend_per_trans_diff]) / 4)
187
+ ) * df[cols.agg.customer_id_p2] - ((df[cols.agg.customer_id_diff] * df[cols.calc.spend_per_cust_diff]) / 8)
188
+
189
+ df[cols.calc.units_per_trans_contrib] = (
190
+ (
191
+ (
192
+ df[cols.calc.spend_per_trans_p2]
193
+ - (df[cols.calc.units_per_trans_p1] * df[cols.calc.price_per_unit_p2])
194
+ - ((df[cols.calc.units_per_trans_diff] * df[cols.calc.price_per_unit_diff]) / 2)
195
+ )
196
+ * df[cols.calc.trans_per_cust_p2]
197
+ )
198
+ - ((df[cols.calc.trans_per_cust_diff] * df[cols.calc.spend_per_trans_diff]) / 4)
199
+ ) * df[cols.agg.customer_id_p2] - ((df[cols.agg.customer_id_diff] * df[cols.calc.spend_per_cust_diff]) / 8)
200
+
201
+ cols = RevenueTree._get_final_col_order(include_quantity=cols.agg.unit_qty in df_cols)
202
+
203
+ return df[cols]
204
+
205
+
206
+ @plugin_manager.extensible
207
+ class RevenueTree:
208
+ """Revenue Tree Analysis Class."""
209
+
210
+ def __init__(
211
+ self,
212
+ df: pd.DataFrame | ibis.Table,
213
+ period_col: str,
214
+ p1_value: str,
215
+ p2_value: str,
216
+ group_col: str | list[str] | None = None,
217
+ ) -> None:
218
+ """Initialize the Revenue Tree Analysis Class.
219
+
220
+ Args:
221
+ df (pd.DataFrame | ibis.Table): The input DataFrame or ibis Table containing transaction data.
222
+ period_col (str): The column representing the period.
223
+ p1_value (str): The value representing the first period.
224
+ p2_value (str): The value representing the second period.
225
+ group_col (str | list[str] | None, optional): The column(s) to group the data by. Can be a single
226
+ column name (str) or a list of column names (list[str]). Defaults to None.
227
+
228
+ Raises:
229
+ ValueError: If the required columns are not present in the DataFrame.
230
+
231
+ Examples:
232
+ Single column grouping:
233
+ tree = RevenueTree(df, period_col="year", p1_value="2023", p2_value="2024", group_col="store")
234
+
235
+ Multi-column grouping:
236
+ tree = RevenueTree(df, period_col="year", p1_value="2023", p2_value="2024",
237
+ group_col=["region", "store"])
238
+ """
239
+ cols = ColumnHelper()
240
+
241
+ # Normalize group_col: str -> list[str], None -> None, list[str] -> list[str]
242
+ if isinstance(group_col, str):
243
+ group_col = [group_col]
244
+
245
+ required_cols = [
246
+ cols.customer_id,
247
+ cols.transaction_id,
248
+ cols.unit_spend,
249
+ ]
250
+ if cols.unit_qty in df.columns:
251
+ required_cols.append(cols.unit_qty)
252
+
253
+ if group_col is not None:
254
+ required_cols.extend(group_col)
255
+
256
+ missing_cols = set(required_cols) - set(df.columns)
257
+ if len(missing_cols) > 0:
258
+ msg = f"The following columns are required but missing: {missing_cols}"
259
+ raise ValueError(msg)
260
+
261
+ df, p1_index, p2_index = self._agg_data(df, period_col, p1_value, p2_value, group_col)
262
+
263
+ self.df = calc_tree_kpis(
264
+ df=df,
265
+ p1_index=p1_index,
266
+ p2_index=p2_index,
267
+ )
268
+
269
+ @staticmethod
270
+ def _agg_data(
271
+ df: pd.DataFrame | ibis.Table,
272
+ period_col: str,
273
+ p1_value: str,
274
+ p2_value: str,
275
+ group_col: list[str] | None = None,
276
+ ) -> tuple[pd.DataFrame, list[bool], list[bool]]:
277
+ """Aggregate data by period and optional grouping columns.
278
+
279
+ Args:
280
+ df (pd.DataFrame | ibis.Table): Input DataFrame or ibis Table.
281
+ period_col (str): Column name for the period.
282
+ p1_value (str): Value representing period 1.
283
+ p2_value (str): Value representing period 2.
284
+ group_col (list[str] | None, optional): List of column names to group by. Defaults to None.
285
+
286
+ Returns:
287
+ tuple[pd.DataFrame, list[bool], list[bool]]: Aggregated DataFrame and boolean indices for p1 and p2.
288
+ """
289
+ cols = ColumnHelper()
290
+
291
+ if isinstance(df, pd.DataFrame):
292
+ df: ibis.Table = ibis.memtable(df)
293
+
294
+ aggs = {
295
+ cols.agg.customer_id: df[cols.customer_id].nunique(),
296
+ cols.agg.transaction_id: df[cols.transaction_id].nunique(),
297
+ cols.agg.unit_spend: df[cols.unit_spend].sum(),
298
+ }
299
+ if cols.unit_qty in df.columns:
300
+ aggs[cols.agg.unit_qty] = df[cols.unit_qty].sum()
301
+
302
+ group_by_cols = [*group_col, period_col] if group_col else [period_col]
303
+ df = pd.DataFrame(df.group_by(group_by_cols).aggregate(**aggs).execute())
304
+ p1_df = df[df[period_col] == p1_value].drop(columns=[period_col])
305
+ p2_df = df[df[period_col] == p2_value].drop(columns=[period_col])
306
+
307
+ if group_col is not None:
308
+ p1_df = p1_df.sort_values(by=group_col)
309
+ p2_df = p2_df.sort_values(by=group_col)
310
+
311
+ new_p1_index = [True] * len(p1_df) + [False] * len(p2_df)
312
+ new_p2_index = [not i for i in new_p1_index]
313
+
314
+ result_df = pd.concat([p1_df, p2_df], ignore_index=True)
315
+
316
+ if group_col is None:
317
+ result_df.index = ["p1", "p2"]
318
+ else:
319
+ result_df = result_df.set_index(group_col)
320
+ if len(group_col) == 1:
321
+ result_df.index = pd.CategoricalIndex(result_df.index)
322
+ # else: MultiIndex created automatically by set_index
323
+ return result_df, new_p1_index, new_p2_index
324
+
325
+ @staticmethod
326
+ def _get_final_col_order(include_quantity: bool) -> list[str]:
327
+ """Get the final column order for the RevenueTree DataFrame.
328
+
329
+ Args:
330
+ include_quantity: Whether to include quantity-related columns.
331
+
332
+ Returns:
333
+ list[str]: Ordered list of column names for the final DataFrame.
334
+
335
+ """
336
+ cols = ColumnHelper()
337
+ col_order = [
338
+ # Customers
339
+ cols.agg.customer_id_p1,
340
+ cols.agg.customer_id_p2,
341
+ cols.agg.customer_id_diff,
342
+ cols.agg.customer_id_pct_diff,
343
+ cols.agg.customer_id_contrib,
344
+ # Transactions
345
+ cols.agg.transaction_id_p1,
346
+ cols.agg.transaction_id_p2,
347
+ cols.agg.transaction_id_diff,
348
+ cols.agg.transaction_id_pct_diff,
349
+ # Unit Spend
350
+ cols.agg.unit_spend_p1,
351
+ cols.agg.unit_spend_p2,
352
+ cols.agg.unit_spend_diff,
353
+ cols.agg.unit_spend_pct_diff,
354
+ # Spend / Customer
355
+ cols.calc.spend_per_cust_p1,
356
+ cols.calc.spend_per_cust_p2,
357
+ cols.calc.spend_per_cust_diff,
358
+ cols.calc.spend_per_cust_pct_diff,
359
+ cols.calc.spend_per_cust_contrib,
360
+ # Transactions / Customer
361
+ cols.calc.trans_per_cust_p1,
362
+ cols.calc.trans_per_cust_p2,
363
+ cols.calc.trans_per_cust_diff,
364
+ cols.calc.trans_per_cust_pct_diff,
365
+ cols.calc.trans_per_cust_contrib,
366
+ # Spend / Transaction
367
+ cols.calc.spend_per_trans_p1,
368
+ cols.calc.spend_per_trans_p2,
369
+ cols.calc.spend_per_trans_diff,
370
+ cols.calc.spend_per_trans_pct_diff,
371
+ cols.calc.spend_per_trans_contrib,
372
+ # Elasticity
373
+ cols.calc.frequency_elasticity,
374
+ ]
375
+
376
+ if include_quantity:
377
+ col_order.extend(
378
+ [
379
+ # Unit Quantity
380
+ cols.agg.unit_qty_p1,
381
+ cols.agg.unit_qty_p2,
382
+ cols.agg.unit_qty_diff,
383
+ cols.agg.unit_qty_pct_diff,
384
+ # Quantity / Transaction
385
+ cols.calc.units_per_trans_p1,
386
+ cols.calc.units_per_trans_p2,
387
+ cols.calc.units_per_trans_diff,
388
+ cols.calc.units_per_trans_pct_diff,
389
+ cols.calc.units_per_trans_contrib,
390
+ # Price / Unit
391
+ cols.calc.price_per_unit_p1,
392
+ cols.calc.price_per_unit_p2,
393
+ cols.calc.price_per_unit_diff,
394
+ cols.calc.price_per_unit_pct_diff,
395
+ cols.calc.price_per_unit_contrib,
396
+ # Price Elasticity
397
+ cols.calc.price_elasticity,
398
+ ],
399
+ )
400
+
401
+ return col_order
402
+
403
+ def draw_tree(
404
+ self,
405
+ row_index: int = 0,
406
+ value_labels: tuple[str, str] | None = None,
407
+ unit_spend_label: str = "Revenue",
408
+ customer_id_label: str = "Customers",
409
+ spend_per_customer_label: str = "Spend / Customer",
410
+ transactions_per_customer_label: str = "Visits / Customer",
411
+ spend_per_transaction_label: str = "Spend / Visit",
412
+ units_per_transaction_label: str = "Units / Visit",
413
+ price_per_unit_label: str = "Price / Unit",
414
+ ) -> Axes:
415
+ """Draw the Revenue Tree graph as a matplotlib visualization.
416
+
417
+ Args:
418
+ row_index (int, optional): Index of the row to visualize from the RevenueTree DataFrame. Defaults to 0.
419
+ Useful when the RevenueTree has multiple groups (e.g., by region, store, etc.).
420
+ value_labels (tuple[str, str] | None, optional): Labels for period columns. If None, uses "Current Period"
421
+ and "Previous Period". If provided, should be a tuple of (current_label, previous_label).
422
+ unit_spend_label (str, optional): Label for the Revenue node. Defaults to "Revenue".
423
+ customer_id_label (str, optional): Label for the Customers node. Defaults to "Customers".
424
+ spend_per_customer_label (str, optional): Label for the Spend / Customer node.
425
+ Defaults to "Spend / Customer".
426
+ transactions_per_customer_label (str, optional): Label for the Visits / Customer node.
427
+ Defaults to "Visits / Customer".
428
+ spend_per_transaction_label (str, optional): Label for the Spend / Visit node.
429
+ Defaults to "Spend / Visit".
430
+ units_per_transaction_label (str, optional): Label for the Units / Visit node.
431
+ Defaults to "Units / Visit".
432
+ price_per_unit_label (str, optional): Label for the Price / Unit node. Defaults to "Price / Unit".
433
+
434
+ Returns:
435
+ matplotlib.axes.Axes: The matplotlib axes containing the tree visualization.
436
+
437
+ Raises:
438
+ IndexError: If row_index is out of bounds for the DataFrame.
439
+
440
+ """
441
+ cols = ColumnHelper()
442
+ graph_data = self.df.iloc[row_index].to_dict()
443
+
444
+ # Set period labels
445
+ current_label, previous_label = value_labels or ("Current Period", "Previous Period")
446
+
447
+ # Build tree structure - always include base 5 nodes
448
+ tree_structure = {
449
+ "revenue": {
450
+ "header": unit_spend_label,
451
+ "percent": graph_data[cols.agg.unit_spend_pct_diff] * 100,
452
+ "current_period": gu.human_format(graph_data[cols.agg.unit_spend_p2], decimals=2),
453
+ "previous_period": gu.human_format(graph_data[cols.agg.unit_spend_p1], decimals=2),
454
+ "diff": gu.human_format(graph_data[cols.agg.unit_spend_diff], decimals=2),
455
+ # Contribution omitted for root node (would be same as diff)
456
+ "current_label": current_label,
457
+ "previous_label": previous_label,
458
+ "position": (1, 0),
459
+ "children": ["customers", "spend_per_customer"],
460
+ },
461
+ "customers": {
462
+ "header": customer_id_label,
463
+ "percent": graph_data[cols.agg.customer_id_pct_diff] * 100,
464
+ "current_period": gu.human_format(graph_data[cols.agg.customer_id_p2], decimals=2),
465
+ "previous_period": gu.human_format(graph_data[cols.agg.customer_id_p1], decimals=2),
466
+ "diff": gu.human_format(graph_data[cols.agg.customer_id_diff], decimals=2),
467
+ "contribution": gu.human_format(graph_data[cols.agg.customer_id_contrib], decimals=2),
468
+ "current_label": current_label,
469
+ "previous_label": previous_label,
470
+ "position": (0, 1),
471
+ "children": [],
472
+ },
473
+ "spend_per_customer": {
474
+ "header": spend_per_customer_label,
475
+ "percent": graph_data[cols.calc.spend_per_cust_pct_diff] * 100,
476
+ "current_period": gu.human_format(graph_data[cols.calc.spend_per_cust_p2], decimals=2),
477
+ "previous_period": gu.human_format(graph_data[cols.calc.spend_per_cust_p1], decimals=2),
478
+ "diff": gu.human_format(graph_data[cols.calc.spend_per_cust_diff], decimals=2),
479
+ "contribution": gu.human_format(graph_data[cols.calc.spend_per_cust_contrib], decimals=2),
480
+ "current_label": current_label,
481
+ "previous_label": previous_label,
482
+ "position": (2, 1),
483
+ "children": ["visits_per_customer", "spend_per_visit"],
484
+ },
485
+ "visits_per_customer": {
486
+ "header": transactions_per_customer_label,
487
+ "percent": graph_data[cols.calc.trans_per_cust_pct_diff] * 100,
488
+ "current_period": gu.human_format(graph_data[cols.calc.trans_per_cust_p2], decimals=2),
489
+ "previous_period": gu.human_format(graph_data[cols.calc.trans_per_cust_p1], decimals=2),
490
+ "diff": gu.human_format(graph_data[cols.calc.trans_per_cust_diff], decimals=2),
491
+ "contribution": gu.human_format(graph_data[cols.calc.trans_per_cust_contrib], decimals=2),
492
+ "current_label": current_label,
493
+ "previous_label": previous_label,
494
+ "position": (1, 2),
495
+ "children": [],
496
+ },
497
+ "spend_per_visit": {
498
+ "header": spend_per_transaction_label,
499
+ "percent": graph_data[cols.calc.spend_per_trans_pct_diff] * 100,
500
+ "current_period": gu.human_format(graph_data[cols.calc.spend_per_trans_p2], decimals=2),
501
+ "previous_period": gu.human_format(graph_data[cols.calc.spend_per_trans_p1], decimals=2),
502
+ "diff": gu.human_format(graph_data[cols.calc.spend_per_trans_diff], decimals=2),
503
+ "contribution": gu.human_format(graph_data[cols.calc.spend_per_trans_contrib], decimals=2),
504
+ "current_label": current_label,
505
+ "previous_label": previous_label,
506
+ "position": (3, 2),
507
+ "children": [],
508
+ },
509
+ }
510
+
511
+ grid_rows = 3
512
+ grid_cols = 4
513
+
514
+ # Add quantity-related nodes if data is available
515
+ has_quantity = cols.agg.unit_qty_p1 in graph_data
516
+ if has_quantity:
517
+ grid_rows = 4
518
+ grid_cols = 5
519
+ tree_structure["spend_per_visit"]["children"] = ["units_per_visit", "price_per_unit"]
520
+ tree_structure["units_per_visit"] = {
521
+ "header": units_per_transaction_label,
522
+ "percent": graph_data[cols.calc.units_per_trans_pct_diff] * 100,
523
+ "current_period": gu.human_format(graph_data[cols.calc.units_per_trans_p2], decimals=2),
524
+ "previous_period": gu.human_format(graph_data[cols.calc.units_per_trans_p1], decimals=2),
525
+ "diff": gu.human_format(graph_data[cols.calc.units_per_trans_diff], decimals=2),
526
+ "contribution": gu.human_format(graph_data[cols.calc.units_per_trans_contrib], decimals=2),
527
+ "current_label": current_label,
528
+ "previous_label": previous_label,
529
+ "position": (2, 3),
530
+ "children": [],
531
+ }
532
+ tree_structure["price_per_unit"] = {
533
+ "header": price_per_unit_label,
534
+ "percent": graph_data[cols.calc.price_per_unit_pct_diff] * 100,
535
+ "current_period": gu.human_format(graph_data[cols.calc.price_per_unit_p2], decimals=2),
536
+ "previous_period": gu.human_format(graph_data[cols.calc.price_per_unit_p1], decimals=2),
537
+ "diff": gu.human_format(graph_data[cols.calc.price_per_unit_diff], decimals=2),
538
+ "contribution": gu.human_format(graph_data[cols.calc.price_per_unit_contrib], decimals=2),
539
+ "current_label": current_label,
540
+ "previous_label": previous_label,
541
+ "position": (4, 3),
542
+ "children": [],
543
+ }
544
+
545
+ # Create and render the tree grid
546
+ grid = TreeGrid(
547
+ tree_structure=tree_structure,
548
+ num_rows=grid_rows,
549
+ num_cols=grid_cols,
550
+ node_class=DetailedTreeNode,
551
+ )
552
+
553
+ return grid.render()