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,505 @@
|
|
|
1
|
+
"""This module provides functionality for creating index plots in retail analytics.
|
|
2
|
+
|
|
3
|
+
Index plots are useful for comparing the performance of different categories or segments against
|
|
4
|
+
a baseline or average, typically set at 100. The module supports customization of the plot's
|
|
5
|
+
appearance, sorting of data, and filtering by specific groups, offering valuable insights into
|
|
6
|
+
retail operations.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **Index Plot Creation**: Visualize how categories or segments perform relative to a baseline
|
|
12
|
+
value, typically set at 100. Useful for comparing performance across products, regions, or
|
|
13
|
+
customer segments.
|
|
14
|
+
- **Flexible Sorting**: Sort data by either group or value to highlight specific trends.
|
|
15
|
+
- **Data Filtering**: Filter data based on specified groups to focus on specific categories
|
|
16
|
+
or exclude unwanted data.
|
|
17
|
+
- **Highlighting Range**: Highlight specific ranges of values (e.g., performance range between
|
|
18
|
+
80-120) to focus on performance.
|
|
19
|
+
- **Series Support**: Optionally include a `series_col` for plotting multiple series (e.g.,
|
|
20
|
+
time periods) within the same plot.
|
|
21
|
+
- **Graph Customization**: Adjust titles, axis labels, legend titles, and styling to match the
|
|
22
|
+
specific context of the analysis.
|
|
23
|
+
|
|
24
|
+
### Use Cases
|
|
25
|
+
|
|
26
|
+
- **Retail Performance Comparison**: Compare product or regional performance to the company
|
|
27
|
+
average or baseline using an index plot.
|
|
28
|
+
- **Customer Segment Analysis**: Evaluate customer segment behavior against overall performance,
|
|
29
|
+
helping identify high-performing segments.
|
|
30
|
+
- **Operational Insights**: Identify areas of concern or opportunity by comparing store, region,
|
|
31
|
+
or product performance against the baseline.
|
|
32
|
+
- **Visualizing Retail Strategy**: Support decision-making by visualizing which categories or
|
|
33
|
+
products overperform or underperform relative to a baseline.
|
|
34
|
+
|
|
35
|
+
### Limitations and Handling of Data
|
|
36
|
+
|
|
37
|
+
- **Data Grouping and Aggregation**: Supports aggregation functions such as sum, average, etc.,
|
|
38
|
+
for calculating the index.
|
|
39
|
+
- **Sorting**: Sorting can be applied by group or value, allowing analysts to focus on specific
|
|
40
|
+
trends. If `series_col` is provided, sorting by `group` is applied.
|
|
41
|
+
- **Group Filtering**: Users can exclude or include specific groups for focused analysis, with
|
|
42
|
+
error handling to ensure conflicting options are not used simultaneously.
|
|
43
|
+
|
|
44
|
+
### Functionality Details
|
|
45
|
+
|
|
46
|
+
- **plot()**: Generates the index plot, which can be customized with multiple options such as
|
|
47
|
+
sorting, filtering, and styling.
|
|
48
|
+
- **get_indexes()**: Helper function for calculating the index of the value column for a given
|
|
49
|
+
subset of the dataframe based on filters and aggregation.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from typing import Literal
|
|
54
|
+
|
|
55
|
+
import ibis
|
|
56
|
+
import matplotlib.pyplot as plt
|
|
57
|
+
import numpy as np
|
|
58
|
+
import pandas as pd
|
|
59
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
60
|
+
|
|
61
|
+
import openretailscience.plots.styles.graph_utils as gu
|
|
62
|
+
from openretailscience.plots.styles.colors import get_linear_cmap, get_named_color
|
|
63
|
+
|
|
64
|
+
BASELINE_INDEX = 100
|
|
65
|
+
DEFAULT_HIGHLIGHT_RANGE = (80, 120)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def filter_by_groups(
|
|
69
|
+
df: pd.DataFrame,
|
|
70
|
+
group_col: str,
|
|
71
|
+
exclude_groups: list[any] | None = None,
|
|
72
|
+
include_only_groups: list[any] | None = None,
|
|
73
|
+
) -> pd.DataFrame:
|
|
74
|
+
"""Filter dataframe by groups.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
df (pd.DataFrame): The dataframe to filter.
|
|
78
|
+
group_col (str): The column name for grouping.
|
|
79
|
+
exclude_groups (list[any], optional): Groups to exclude. Defaults to None.
|
|
80
|
+
include_only_groups (list[any], optional): Groups to include. Defaults to None.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
pd.DataFrame: The filtered dataframe.
|
|
84
|
+
"""
|
|
85
|
+
result_df = df.copy()
|
|
86
|
+
if exclude_groups is not None:
|
|
87
|
+
result_df = result_df[~result_df[group_col].isin(exclude_groups)]
|
|
88
|
+
if include_only_groups is not None:
|
|
89
|
+
result_df = result_df[result_df[group_col].isin(include_only_groups)]
|
|
90
|
+
return result_df
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def filter_by_value_thresholds(
|
|
94
|
+
df: pd.DataFrame,
|
|
95
|
+
filter_above: float | None = None,
|
|
96
|
+
filter_below: float | None = None,
|
|
97
|
+
) -> pd.DataFrame:
|
|
98
|
+
"""Filter dataframe by index value thresholds.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
df (pd.DataFrame): The dataframe to filter.
|
|
102
|
+
filter_above (float, optional): Only keep indices above this value. Defaults to None.
|
|
103
|
+
filter_below (float, optional): Only keep indices below this value. Defaults to None.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
pd.DataFrame: The filtered dataframe.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If filtering results in an empty dataset.
|
|
110
|
+
"""
|
|
111
|
+
result_df = df.copy()
|
|
112
|
+
if filter_above is not None:
|
|
113
|
+
result_df = result_df[result_df["index"] > filter_above]
|
|
114
|
+
if filter_below is not None:
|
|
115
|
+
result_df = result_df[result_df["index"] < filter_below]
|
|
116
|
+
|
|
117
|
+
# Check if filtering resulted in an empty dataframe
|
|
118
|
+
if len(result_df) == 0:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"Filtering resulted in an empty dataset. Consider adjusting filter parameters.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return result_df
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def filter_top_bottom_n(df: pd.DataFrame, top_n: int | None = None, bottom_n: int | None = None) -> pd.DataFrame:
|
|
127
|
+
"""Filter dataframe to include only top N and/or bottom N rows by index value.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
df (pd.DataFrame): The dataframe to filter.
|
|
131
|
+
top_n (int, optional): Number of top items to include. Defaults to None.
|
|
132
|
+
bottom_n (int, optional): Number of bottom items to include. Defaults to None.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
pd.DataFrame: The filtered dataframe.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValueError: If top_n or bottom_n exceed the available groups.
|
|
139
|
+
ValueError: If the sum of top_n and bottom_n exceeds the total number of groups.
|
|
140
|
+
ValueError: If filtering results in an empty dataset.
|
|
141
|
+
"""
|
|
142
|
+
top_n = None if top_n == 0 else top_n
|
|
143
|
+
bottom_n = None if bottom_n == 0 else bottom_n
|
|
144
|
+
|
|
145
|
+
if (top_n is None and bottom_n is None) or len(df) == 0:
|
|
146
|
+
return df
|
|
147
|
+
|
|
148
|
+
# Check if top_n or bottom_n exceed the dataframe length
|
|
149
|
+
df_length = len(df)
|
|
150
|
+
if top_n is not None and top_n > df_length:
|
|
151
|
+
error_msg = f"top_n ({top_n}) cannot exceed the number of available groups ({df_length})"
|
|
152
|
+
raise ValueError(error_msg)
|
|
153
|
+
if bottom_n is not None and bottom_n > df_length:
|
|
154
|
+
error_msg = f"bottom_n ({bottom_n}) cannot exceed the number of available groups ({df_length})"
|
|
155
|
+
raise ValueError(error_msg)
|
|
156
|
+
|
|
157
|
+
# Check if top_n + bottom_n exceeds total groups
|
|
158
|
+
if top_n is not None and bottom_n is not None and top_n + bottom_n > df_length:
|
|
159
|
+
error_msg = (
|
|
160
|
+
f"The sum of top_n ({top_n}) and bottom_n ({bottom_n}) cannot exceed"
|
|
161
|
+
f" the total number of groups ({df_length})"
|
|
162
|
+
)
|
|
163
|
+
raise ValueError(error_msg)
|
|
164
|
+
|
|
165
|
+
# Create a temporary dataframe sorted by index value
|
|
166
|
+
temp_df = df.copy().sort_values(by="index", ascending=False)
|
|
167
|
+
|
|
168
|
+
selected_rows = pd.DataFrame()
|
|
169
|
+
if top_n is not None:
|
|
170
|
+
selected_rows = pd.concat([selected_rows, temp_df.head(top_n)])
|
|
171
|
+
if bottom_n is not None:
|
|
172
|
+
selected_rows = pd.concat([selected_rows, temp_df.tail(bottom_n)])
|
|
173
|
+
|
|
174
|
+
return selected_rows
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def plot( # noqa: C901, PLR0913
|
|
178
|
+
df: pd.DataFrame,
|
|
179
|
+
value_col: str,
|
|
180
|
+
group_col: str,
|
|
181
|
+
index_col: str,
|
|
182
|
+
value_to_index: str,
|
|
183
|
+
agg_func: str = "sum",
|
|
184
|
+
series_col: str | None = None,
|
|
185
|
+
title: str | None = None,
|
|
186
|
+
x_label: str = "Index",
|
|
187
|
+
y_label: str | None = None,
|
|
188
|
+
legend_title: str | None = None,
|
|
189
|
+
highlight_range: Literal["default"] | tuple[float, float] | None = "default",
|
|
190
|
+
sort_by: Literal["group", "value"] | None = "group",
|
|
191
|
+
sort_order: Literal["ascending", "descending"] = "ascending",
|
|
192
|
+
ax: Axes | None = None,
|
|
193
|
+
source_text: str | None = None,
|
|
194
|
+
exclude_groups: list[any] | None = None,
|
|
195
|
+
include_only_groups: list[any] | None = None,
|
|
196
|
+
drop_na: bool = False,
|
|
197
|
+
top_n: int | None = None,
|
|
198
|
+
bottom_n: int | None = None,
|
|
199
|
+
filter_above: float | None = None,
|
|
200
|
+
filter_below: float | None = None,
|
|
201
|
+
color_by_threshold: bool = False,
|
|
202
|
+
**kwargs: dict[str, any],
|
|
203
|
+
) -> SubplotBase:
|
|
204
|
+
"""Creates an index plot.
|
|
205
|
+
|
|
206
|
+
Index plots are visual tools used in retail analytics to compare different categories or segments against a
|
|
207
|
+
baseline or average value, typically set at 100. Index plots allow analysts to:
|
|
208
|
+
|
|
209
|
+
- Quickly identify which categories over- or underperform relative to the average
|
|
210
|
+
- Compare performance across diverse categories on a standardized scale
|
|
211
|
+
- Highlight areas of opportunity or concern in retail operations
|
|
212
|
+
- Easily communicate relative performance to stakeholders without revealing sensitive absolute numbers
|
|
213
|
+
|
|
214
|
+
In retail contexts, index plots are valuable for:
|
|
215
|
+
|
|
216
|
+
- Comparing sales performance across product categories
|
|
217
|
+
- Analyzing customer segment behavior against the overall average
|
|
218
|
+
- Evaluating store or regional performance relative to company-wide metrics
|
|
219
|
+
- Identifying high-potential areas for growth or investment
|
|
220
|
+
|
|
221
|
+
By normalizing data to an index, these plots facilitate meaningful comparisons and help focus attention on
|
|
222
|
+
significant deviations from expected performance, supporting more informed decision-making in retail strategy and
|
|
223
|
+
operations.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
df (pd.DataFrame): The dataframe to plot.
|
|
227
|
+
value_col (str): The column to plot.
|
|
228
|
+
group_col (str): The column to group the data by.
|
|
229
|
+
index_col (str): The column to calculate the index on (e.g., "category").
|
|
230
|
+
value_to_index (str): The baseline category or value to index against (e.g., "A").
|
|
231
|
+
agg_func (str, optional): The aggregation function to apply to the value_col. Defaults to "sum".
|
|
232
|
+
series_col (str, optional): The column to use as the series. Defaults to None.
|
|
233
|
+
title (str, optional): The title of the plot. Defaults to None. When None the title is set to
|
|
234
|
+
`f"{value_col.title()} by {group_col.title()}"`
|
|
235
|
+
x_label (str, optional): The x-axis label. Defaults to "Index".
|
|
236
|
+
y_label (str, optional): The y-axis label. Defaults to None. When None the y-axis label is set to the title
|
|
237
|
+
case of `group_col`
|
|
238
|
+
legend_title (str, optional): The title of the legend. Defaults to None. When None the legend title is set to
|
|
239
|
+
the title case of `group_col`
|
|
240
|
+
highlight_range (Literal["default"] | tuple[float, float] | None, optional): The range to highlight. Defaults
|
|
241
|
+
to "default". When "default" the range is set to (80, 120). When None no range is highlighted.
|
|
242
|
+
sort_by (Literal["group", "value"] | None, optional): The column to sort by. Defaults to "group". When None the
|
|
243
|
+
data is not sorted. When "group" the data is sorted by group_col. When "value" the data is sorted by
|
|
244
|
+
the value_col. When series_col is not None this option is ignored.
|
|
245
|
+
sort_order (Literal["ascending", "descending"], optional): The order to sort the data. Defaults to "ascending".
|
|
246
|
+
ax (Axes, optional): The matplotlib axes object to plot on. Defaults to None.
|
|
247
|
+
source_text (str, optional): The source text to add to the plot. Defaults to None.
|
|
248
|
+
exclude_groups (list[any], optional): The groups to exclude from the plot. Defaults to None.
|
|
249
|
+
include_only_groups (list[any], optional): The groups to include in the plot. Defaults to None. When None all
|
|
250
|
+
groups are included. When not None only the groups in the list are included. Can not be used with
|
|
251
|
+
exclude_groups.
|
|
252
|
+
drop_na (bool, optional): Whether to drop NA index values. Defaults to False.
|
|
253
|
+
top_n (int, optional): Display only the top N indexes by value. Only applicable
|
|
254
|
+
when series_col is None. Defaults to None.
|
|
255
|
+
bottom_n (int, optional): Display only the bottom N indexes by value. Only applicable
|
|
256
|
+
when series_col is None. Defaults to None.
|
|
257
|
+
filter_above (float, optional): Only display indexes above this value. Only applicable
|
|
258
|
+
when series_col is None. Defaults to None.
|
|
259
|
+
filter_below (float, optional): Only display indexes below this value. Only applicable
|
|
260
|
+
when series_col is None. Defaults to None.
|
|
261
|
+
color_by_threshold (bool, optional): Color bars based on highlight_range thresholds using configurable option
|
|
262
|
+
colors. Values >= the upper threshold use the ``plot.color.positive`` option, values <= the lower
|
|
263
|
+
threshold use the ``plot.color.negative`` option, and values between use the ``plot.color.neutral`` option.
|
|
264
|
+
Requires highlight_range to be set (not None). Only applicable when series_col is None. Defaults to False.
|
|
265
|
+
**kwargs: Additional keyword arguments to pass to the Pandas plot function. When
|
|
266
|
+
``color_by_threshold`` is True, kwargs are passed to matplotlib's ``Axes.barh()`` instead
|
|
267
|
+
and pandas-specific kwargs (figsize, stacked, legend, subplots, layout) are filtered out.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
SubplotBase: The matplotlib axes object.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: If sort_by is not either "group" or "value" or None.
|
|
274
|
+
ValueError: If sort_order is not either "ascending" or "descending".
|
|
275
|
+
ValueError: If exclude_groups and include_only_groups are used together.
|
|
276
|
+
ValueError: If both top_n and bottom_n are provided but their sum exceeds the total number of groups.
|
|
277
|
+
ValueError: If top_n or bottom_n exceed the number of available groups.
|
|
278
|
+
ValueError: If top_n, bottom_n, filter_above, or filter_below are used when series_col is provided.
|
|
279
|
+
ValueError: If color_by_threshold is True but highlight_range is None.
|
|
280
|
+
ValueError: If color_by_threshold is True when series_col is provided.
|
|
281
|
+
ValueError: If filtering results in an empty dataset.
|
|
282
|
+
"""
|
|
283
|
+
if sort_by not in ["group", "value", None]:
|
|
284
|
+
raise ValueError("sort_by must be either 'group' or 'value' or None")
|
|
285
|
+
if series_col is not None and sort_by == "value":
|
|
286
|
+
raise ValueError("sort_by cannot be 'value' when series_col is provided")
|
|
287
|
+
if sort_order not in ["ascending", "descending"]:
|
|
288
|
+
raise ValueError("sort_order must be either 'ascending' or 'descending'")
|
|
289
|
+
if exclude_groups is not None and include_only_groups is not None:
|
|
290
|
+
raise ValueError("exclude_groups and include_only_groups cannot be used together")
|
|
291
|
+
if series_col is not None and (
|
|
292
|
+
top_n is not None or bottom_n is not None or filter_above is not None or filter_below is not None
|
|
293
|
+
):
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"top_n, bottom_n, filter_above, and filter_below cannot be used when series_col is provided",
|
|
296
|
+
)
|
|
297
|
+
if color_by_threshold:
|
|
298
|
+
if highlight_range is None:
|
|
299
|
+
raise ValueError("color_by_threshold requires highlight_range to be set (not None)")
|
|
300
|
+
if series_col is not None:
|
|
301
|
+
raise ValueError("color_by_threshold cannot be used when series_col is provided")
|
|
302
|
+
|
|
303
|
+
if highlight_range == "default":
|
|
304
|
+
highlight_range = DEFAULT_HIGHLIGHT_RANGE
|
|
305
|
+
|
|
306
|
+
index_df = get_indexes(
|
|
307
|
+
df=df,
|
|
308
|
+
index_col=index_col,
|
|
309
|
+
value_to_index=value_to_index,
|
|
310
|
+
index_subgroup_col=series_col,
|
|
311
|
+
value_col=value_col,
|
|
312
|
+
agg_func=agg_func,
|
|
313
|
+
offset=BASELINE_INDEX,
|
|
314
|
+
group_col=group_col,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if drop_na:
|
|
318
|
+
index_df = index_df.dropna(subset=["index"])
|
|
319
|
+
|
|
320
|
+
index_df = filter_by_groups(
|
|
321
|
+
df=index_df,
|
|
322
|
+
group_col=group_col,
|
|
323
|
+
exclude_groups=exclude_groups,
|
|
324
|
+
include_only_groups=include_only_groups,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if series_col is None:
|
|
328
|
+
index_df = filter_by_value_thresholds(
|
|
329
|
+
df=index_df,
|
|
330
|
+
filter_above=filter_above,
|
|
331
|
+
filter_below=filter_below,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
default_colors = get_named_color("primary")
|
|
335
|
+
show_legend = False
|
|
336
|
+
index_df = index_df[[group_col, "index"]].set_index(group_col)
|
|
337
|
+
index_df = filter_top_bottom_n(
|
|
338
|
+
df=index_df,
|
|
339
|
+
top_n=top_n,
|
|
340
|
+
bottom_n=bottom_n,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if sort_by in ["group", "value"]:
|
|
344
|
+
index_df = index_df.sort_values(
|
|
345
|
+
by=group_col if sort_by == "group" else "index",
|
|
346
|
+
ascending=sort_order == "ascending",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
else:
|
|
350
|
+
show_legend = True
|
|
351
|
+
default_colors = get_linear_cmap("green")(np.linspace(0, 1, df[series_col].nunique()))
|
|
352
|
+
|
|
353
|
+
if sort_by == "group":
|
|
354
|
+
index_df = index_df.sort_values(by=[group_col, series_col], ascending=sort_order == "ascending")
|
|
355
|
+
|
|
356
|
+
index_df = index_df.pivot_table(
|
|
357
|
+
index=group_col,
|
|
358
|
+
columns=series_col,
|
|
359
|
+
values="index",
|
|
360
|
+
sort=False,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
width = kwargs.pop("width", 0.8)
|
|
364
|
+
color = kwargs.pop("color", default_colors)
|
|
365
|
+
|
|
366
|
+
if color_by_threshold:
|
|
367
|
+
positive_color = get_named_color("positive")
|
|
368
|
+
negative_color = get_named_color("negative")
|
|
369
|
+
neutral_color = get_named_color("neutral")
|
|
370
|
+
|
|
371
|
+
values = index_df["index"].to_numpy() + BASELINE_INDEX
|
|
372
|
+
bar_colors = np.select(
|
|
373
|
+
[values >= highlight_range[1], values <= highlight_range[0]],
|
|
374
|
+
[positive_color, negative_color],
|
|
375
|
+
default=neutral_color,
|
|
376
|
+
)
|
|
377
|
+
if ax is None:
|
|
378
|
+
figsize = kwargs.get("figsize")
|
|
379
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
380
|
+
_pandas_only_kwargs = {"figsize", "stacked", "legend", "subplots", "layout"}
|
|
381
|
+
mpl_kwargs = {k: v for k, v in kwargs.items() if k not in _pandas_only_kwargs}
|
|
382
|
+
ax.barh(
|
|
383
|
+
y=index_df.index,
|
|
384
|
+
width=index_df["index"].to_numpy(),
|
|
385
|
+
left=BASELINE_INDEX,
|
|
386
|
+
color=bar_colors,
|
|
387
|
+
height=width,
|
|
388
|
+
zorder=2,
|
|
389
|
+
**mpl_kwargs,
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
ax = index_df.plot.barh(
|
|
393
|
+
left=BASELINE_INDEX,
|
|
394
|
+
legend=show_legend,
|
|
395
|
+
ax=ax,
|
|
396
|
+
color=color,
|
|
397
|
+
width=width,
|
|
398
|
+
zorder=2,
|
|
399
|
+
**kwargs,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
ax.axvline(BASELINE_INDEX, color="black", linewidth=1, alpha=0.5)
|
|
403
|
+
if highlight_range is not None:
|
|
404
|
+
ax.axvline(highlight_range[0], color="black", linewidth=0.25, alpha=0.1, zorder=-1)
|
|
405
|
+
ax.axvline(highlight_range[1], color="black", linewidth=0.25, alpha=0.1, zorder=-1)
|
|
406
|
+
ax.axvspan(highlight_range[0], highlight_range[1], color="black", alpha=0.1, zorder=-1)
|
|
407
|
+
|
|
408
|
+
default_title = f"{value_col.title()} by {group_col.title()}"
|
|
409
|
+
|
|
410
|
+
ax = gu.standard_graph_styles(
|
|
411
|
+
ax=ax,
|
|
412
|
+
title=gu.not_none(title, default_title),
|
|
413
|
+
x_label=gu.not_none(x_label, "Index"),
|
|
414
|
+
y_label=gu.not_none(y_label, group_col.title()),
|
|
415
|
+
legend_title=legend_title,
|
|
416
|
+
show_legend=show_legend,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if source_text is not None:
|
|
420
|
+
gu.add_source_text(ax=ax, source_text=source_text)
|
|
421
|
+
|
|
422
|
+
gu.standard_tick_styles(ax)
|
|
423
|
+
|
|
424
|
+
return ax
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_indexes(
|
|
428
|
+
df: pd.DataFrame | ibis.Table,
|
|
429
|
+
value_to_index: str,
|
|
430
|
+
index_col: str,
|
|
431
|
+
value_col: str,
|
|
432
|
+
group_col: str,
|
|
433
|
+
index_subgroup_col: str | None = None,
|
|
434
|
+
agg_func: str = "sum",
|
|
435
|
+
offset: int = 0,
|
|
436
|
+
) -> pd.DataFrame:
|
|
437
|
+
"""Calculates the index of the value_col using Ibis for efficient computation at scale.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
df (pd.DataFrame | ibis.Table): The dataframe or Ibis table to calculate the index on. Can be a pandas
|
|
441
|
+
dataframe or an Ibis table.
|
|
442
|
+
value_to_index (str): The baseline category or value to index against (e.g., "A").
|
|
443
|
+
index_col (str): The column to calculate the index on (e.g., "category").
|
|
444
|
+
value_col (str): The column to calculate the index on (e.g., "sales").
|
|
445
|
+
group_col (str): The column to group the data by (e.g., "region").
|
|
446
|
+
index_subgroup_col (str, optional): The column to subgroup the index by (e.g., "store_type"). Defaults to None.
|
|
447
|
+
agg_func (str, optional): The aggregation function to apply to the `value_col`. Valid options are "sum", "mean",
|
|
448
|
+
"max", "min", or "nunique". Defaults to "sum".
|
|
449
|
+
offset (int, optional): The offset value to subtract from the index. This allows for adjustments to the index
|
|
450
|
+
values. Defaults to 0.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
pd.DataFrame: The calculated index values with grouping columns.
|
|
454
|
+
"""
|
|
455
|
+
if isinstance(df, pd.DataFrame):
|
|
456
|
+
df = df.copy()
|
|
457
|
+
table = ibis.memtable(df)
|
|
458
|
+
else:
|
|
459
|
+
table = df
|
|
460
|
+
|
|
461
|
+
agg_func = agg_func.lower()
|
|
462
|
+
if agg_func not in {"sum", "mean", "max", "min", "nunique"}:
|
|
463
|
+
raise ValueError("Unsupported aggregation function.")
|
|
464
|
+
|
|
465
|
+
agg_fn = lambda x: getattr(x, agg_func)() # noqa: E731
|
|
466
|
+
|
|
467
|
+
group_cols = [group_col] if index_subgroup_col is None else [index_subgroup_col, group_col]
|
|
468
|
+
|
|
469
|
+
overall_agg = table.group_by(group_cols).aggregate(value=agg_fn(table[value_col]))
|
|
470
|
+
|
|
471
|
+
if index_subgroup_col is None:
|
|
472
|
+
overall_total = overall_agg.value.sum()
|
|
473
|
+
overall_props = overall_agg.mutate(proportion_overall=overall_agg.value / overall_total.nullif(0))
|
|
474
|
+
else:
|
|
475
|
+
overall_total = overall_agg.group_by(index_subgroup_col).aggregate(total=lambda t: t.value.sum())
|
|
476
|
+
overall_props = (
|
|
477
|
+
overall_agg.join(overall_total, index_subgroup_col)
|
|
478
|
+
.mutate(proportion_overall=lambda t: t.value / t.total.nullif(0))
|
|
479
|
+
.drop("total")
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
table = table.filter(table[index_col] == value_to_index)
|
|
483
|
+
subset_agg = table.group_by(group_cols).aggregate(value=agg_fn(table[value_col]))
|
|
484
|
+
|
|
485
|
+
if index_subgroup_col is None:
|
|
486
|
+
subset_total = subset_agg.value.sum().name("total")
|
|
487
|
+
subset_props = subset_agg.mutate(proportion=subset_agg.value / subset_total.nullif(0))
|
|
488
|
+
else:
|
|
489
|
+
subset_total = subset_agg.group_by(index_subgroup_col).aggregate(total=lambda t: t.value.sum())
|
|
490
|
+
subset_props = (
|
|
491
|
+
subset_agg.join(subset_total, index_subgroup_col)
|
|
492
|
+
.filter(lambda t: t.total != 0)
|
|
493
|
+
.mutate(proportion=lambda t: t.value / t.total)
|
|
494
|
+
.drop("total")
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
result = (
|
|
498
|
+
subset_props.join(overall_props, group_cols)
|
|
499
|
+
.mutate(
|
|
500
|
+
index=lambda t: (t.proportion / t.proportion_overall.nullif(0) * 100) - offset,
|
|
501
|
+
)
|
|
502
|
+
.order_by(group_cols)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return result[[*group_cols, "index"]].execute()
|