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,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()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|