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