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
File without changes
@@ -0,0 +1,114 @@
1
+ """This module provides functionality for creating area plots from pandas DataFrames.
2
+
3
+ It is designed to visualize data distributions over time or across categories using filled area charts. These plots
4
+ help highlight trends and comparisons between different groups by stacking or overlaying areas.
5
+
6
+ While this module supports datetime values on the x-axis, the **plots.time_area** module is better suited for
7
+ explicitly time-based visualizations, offering features like resampling and time-based aggregation.
8
+
9
+ ### Core Features
10
+
11
+ - **Flexible X-Axis Handling**: Uses an index or a specified x-axis column (**`x_col`**) for plotting.
12
+ - **Multiple Area Support**: Allows plotting multiple columns (**`value_col`**) or groups (**`group_col`**).
13
+ - **Dynamic Color Mapping**: Automatically selects a colormap based on the number of groups.
14
+ - **Legend Customization**: Supports custom legend titles and the option to move the legend outside the plot.
15
+ - **Source Text**: Provides an option to add source attribution to the plot.
16
+
17
+ ### Use Cases
18
+
19
+ - **Time Series Visualization**: Show trends in a metric over time (e.g., revenue by month).
20
+ - **Stacked Area Charts**: Compare contributions of different groups over time.
21
+ - **Category-Based Area Plots**: Visualize distributions of data across categories.
22
+
23
+ ### Limitations and Warnings
24
+
25
+ - **Handling of Datetime Data**: If a datetime column is passed as **`x_col`**, a warning suggests using
26
+ the **plots.time_area** module for better handling.
27
+ - **Pre-Aggregated Data Required**: The module does not perform data aggregation; data should be pre-aggregated
28
+ before being passed to the function.
29
+ """
30
+
31
+ import pandas as pd
32
+ from matplotlib.axes import Axes, SubplotBase
33
+
34
+ import openretailscience.plots.styles.graph_utils as gu
35
+ from openretailscience.plots.styles.colors import get_plot_colors
36
+
37
+
38
+ def plot(
39
+ df: pd.DataFrame,
40
+ value_col: str | list[str],
41
+ x_label: str | None = None,
42
+ y_label: str | None = None,
43
+ title: str | None = None,
44
+ x_col: str | None = None,
45
+ group_col: str | None = None,
46
+ ax: Axes | None = None,
47
+ source_text: str | None = None,
48
+ legend_title: str | None = None,
49
+ move_legend_outside: bool = False,
50
+ **kwargs: dict[str, any],
51
+ ) -> SubplotBase:
52
+ """Plots an area chart for the given `value_col` over `x_col` or index, with optional grouping by `group_col`.
53
+
54
+ Args:
55
+ df (pd.DataFrame): The dataframe to plot.
56
+ value_col (str or list of str): The column(s) to plot.
57
+ x_label (str, optional): The x-axis label.
58
+ y_label (str, optional): The y-axis label.
59
+ title (str, optional): The title of the plot.
60
+ x_col (str, optional): The column to be used as the x-axis. If None, the index is used.
61
+ group_col (str, optional): The column used to define different areas in the plot.
62
+ legend_title (str, optional): The title of the legend.
63
+ ax (Axes, optional): Matplotlib axes object to plot on.
64
+ source_text (str, optional): The source text to add to the plot.
65
+ move_legend_outside (bool, optional): Move the legend outside the plot.
66
+ **kwargs: Additional keyword arguments for Pandas' `plot` function.
67
+
68
+ Returns:
69
+ SubplotBase: The matplotlib axes object.
70
+
71
+ Raises:
72
+ ValueError: If `value_col` is a list and `group_col` is provided (which causes ambiguity in plotting).
73
+ """
74
+ if isinstance(df, pd.Series):
75
+ df = df.to_frame()
76
+
77
+ if isinstance(value_col, list) and group_col:
78
+ raise ValueError("Cannot use both a list for `value_col` and a `group_col`. Choose one.")
79
+
80
+ if group_col is None:
81
+ pivot_df = df.set_index(x_col if x_col is not None else df.index)[
82
+ [value_col] if isinstance(value_col, str) else value_col
83
+ ]
84
+ else:
85
+ pivot_df = df.pivot(index=x_col if x_col is not None else None, columns=group_col, values=value_col)
86
+
87
+ is_multi_area = (group_col is not None) or (isinstance(value_col, list) and len(value_col) > 1)
88
+
89
+ num_colors = len(pivot_df.columns) if is_multi_area else 1
90
+ default_colors = get_plot_colors(num_colors)
91
+ alpha = kwargs.pop("alpha", 0.7)
92
+ color = kwargs.pop("color", default_colors)
93
+ ax = pivot_df.plot(
94
+ ax=ax,
95
+ kind="area",
96
+ alpha=alpha,
97
+ color=color,
98
+ legend=is_multi_area,
99
+ **kwargs,
100
+ )
101
+
102
+ ax = gu.standard_graph_styles(
103
+ ax=ax,
104
+ title=title,
105
+ x_label=x_label,
106
+ y_label=y_label,
107
+ legend_title=legend_title,
108
+ move_legend_outside=move_legend_outside,
109
+ )
110
+
111
+ if source_text is not None:
112
+ gu.add_source_text(ax=ax, source_text=source_text)
113
+
114
+ return gu.standard_tick_styles(ax)
@@ -0,0 +1,375 @@
1
+ """This module provides flexible functionality for creating bar plots from pandas DataFrames or Series.
2
+
3
+ It allows you to create bar plots with optional grouping, sorting, orientation, and data labels. The module supports
4
+ both single and grouped bar plots, where grouped bars are created by providing a `x_col`, which defines the x-axis
5
+ labels or categories.
6
+
7
+ ### Features
8
+
9
+ - **Single or Grouped Bar Plots**: Plot one or more value columns (`value_col`) as bars. The `x_col` is used to define
10
+ categories or groups on the x-axis (e.g., products, categories, or regions). Grouped bars can be created by
11
+ specifying both `value_col` (list of columns) and `x_col`.
12
+ - **Sorting and Orientation**: Customize the sorting of bars (ascending or descending) and choose between
13
+ vertical (`"v"`, `"vertical"`) or horizontal (`"h"`, `"horizontal"`) bar orientations.
14
+ - **Data Labels**: Add data labels to bars, with options to show absolute values or percentages.
15
+ - **Hatching Patterns**: Apply hatch patterns to the bars for enhanced visual differentiation.
16
+ - **Legend Customization**: Move the legend outside the plot for better visibility, especially when dealing
17
+ with grouped bars or multiple value columns.
18
+
19
+
20
+ ### Use Cases
21
+
22
+ - **Sales and Revenue Analysis**: Visualize sales or revenue across different products or categories by creating grouped
23
+ bar plots (e.g., revenue across quarters or regions). The `x_col` will define the products or categories displayed on
24
+ the x-axis.
25
+ - **Comparative Analysis**: Compare multiple metrics simultaneously by plotting grouped bars. For instance, you can
26
+ compare product sales for different periods side by side, with `x_col` defining the x-axis categories.
27
+ - **Distribution Analysis**: Visualize the distribution of categorical data (e.g., product sales) across different
28
+ categories, where `x_col` defines the x-axis labels.
29
+
30
+ ### Limitations and Handling of Data
31
+ - **Series Support**: The module can also handle pandas Series, though **`x_col`** cannot be provided when plotting a
32
+ Series.
33
+ In this case, the index of the Series will define the x-axis labels.
34
+ """
35
+
36
+ import warnings
37
+ from typing import Any, Literal
38
+
39
+ import pandas as pd
40
+ from matplotlib.axes import Axes, SubplotBase
41
+ from matplotlib.container import BarContainer
42
+ from matplotlib.patches import Rectangle
43
+
44
+ import openretailscience.plots.styles.graph_utils as gu
45
+ from openretailscience.plots.styles.colors import get_plot_colors
46
+
47
+
48
+ def plot(
49
+ df: pd.DataFrame | pd.Series,
50
+ value_col: str | list[str] | None = None,
51
+ x_col: str | None = None,
52
+ title: str | None = None,
53
+ x_label: str | None = None,
54
+ y_label: str | None = None,
55
+ legend_title: str | None = None,
56
+ ax: Axes | None = None,
57
+ source_text: str | None = None,
58
+ move_legend_outside: bool = False,
59
+ orientation: Literal["horizontal", "h", "vertical", "v"] = "vertical",
60
+ sort_order: Literal["ascending", "descending"] | None = None,
61
+ data_label_format: Literal["absolute", "percentage_by_bar_group", "percentage_by_series"] | None = None,
62
+ use_hatch: bool = False,
63
+ num_digits: int = 3,
64
+ **kwargs: dict[str, Any],
65
+ ) -> SubplotBase:
66
+ """Creates a customizable bar plot from a DataFrame or Series.
67
+
68
+ Supports optional sorting, orientation, and data labels. Grouped bars can be created with a grouping column.
69
+
70
+ Args:
71
+ df (pd.DataFrame | pd.Series): The input DataFrame or Series containing the data to be plotted.
72
+ value_col (str | list[str], optional): The column(s) containing values to plot as bars. Multiple value columns
73
+ create grouped bars. Defaults to None.
74
+ x_col (str, optional): The column to group data by, used for grouping bars. Defaults to None.
75
+ title (str, optional): The title of the plot. Defaults to None.
76
+ x_label (str, optional): The label for the x-axis. Defaults to None.
77
+ y_label (str, optional): The label for the y-axis. Defaults to None.
78
+ legend_title (str, optional): The title for the legend. Defaults to None.
79
+ ax (Axes, optional): The Matplotlib Axes object to plot on. Defaults to None.
80
+ source_text (str, optional): Text to be displayed as a source at the bottom of the plot. Defaults to None.
81
+ move_legend_outside (bool, optional): Whether to move the legend outside the plot area. Defaults to False.
82
+ orientation (Literal["horizontal", "h", "vertical", "v"], optional): Orientation of the bars. Can be
83
+ "horizontal", "h", "vertical", or "v".
84
+ Defaults to "vertical".
85
+ sort_order (Literal["ascending", "descending"] | None, optional): Sorting order for the bars. Can be
86
+ "ascending" or "descending". Defaults to None.
87
+ data_label_format (Literal["absolute", "percentage"] | None, optional): Format for displaying data labels.
88
+ "absolute" shows raw values,
89
+ "percentage" shows percentage.
90
+ Defaults to None.
91
+ use_hatch (bool, optional): Whether to apply hatch patterns to the bars. Defaults to False.
92
+ num_digits (int, optional): The number of digits to display in the data labels. Defaults to 3.
93
+ **kwargs (dict[str, any]): Additional keyword arguments for the Pandas `plot` function.
94
+
95
+ Returns:
96
+ SubplotBase: The Matplotlib Axes object with the generated plot.
97
+ """
98
+ if df.empty:
99
+ raise ValueError("Cannot plot with empty DataFrame")
100
+
101
+ # Check if x_col exists in the DataFrame, if provided
102
+ if x_col is not None and x_col not in df.columns:
103
+ msg = f"x_col '{x_col}' not found in DataFrame"
104
+ raise KeyError(msg)
105
+
106
+ valid_orientations = ["horizontal", "h", "vertical", "v"]
107
+ if orientation not in valid_orientations:
108
+ error_msg = f"Invalid orientation: {orientation}. Expected one of {valid_orientations}"
109
+ raise ValueError(error_msg)
110
+
111
+ # Validate the sort_order value
112
+ valid_sort_orders = ["ascending", "descending", None]
113
+ if sort_order not in valid_sort_orders:
114
+ error_msg = f"Invalid sort_order: {sort_order}. Expected one of {valid_sort_orders}"
115
+ raise ValueError(error_msg)
116
+
117
+ # Validate the data_label_format value
118
+ valid_data_label_formats = ["absolute", "percentage_by_bar_group", "percentage_by_series", None]
119
+ if data_label_format not in valid_data_label_formats:
120
+ error_msg = f"Invalid data_label_format: {data_label_format}. Expected one of {valid_data_label_formats}"
121
+ raise ValueError(error_msg)
122
+
123
+ width = kwargs.pop("width", 0.8)
124
+
125
+ value_col = [value_col] if isinstance(value_col, str) else (["Value"] if value_col is None else value_col)
126
+
127
+ df = df.to_frame(name=value_col[0]) if isinstance(df, pd.Series) else df
128
+
129
+ if data_label_format in ["percentage_by_bar_group", "percentage_by_series"] and (df[value_col] < 0).any().any():
130
+ warnings.warn(
131
+ f"Negative values detected in {value_col}. This may lead to unexpected behavior in terms of the data "
132
+ f"label format '{data_label_format}'.",
133
+ UserWarning,
134
+ stacklevel=2,
135
+ )
136
+
137
+ df = df.sort_values(by=value_col[0], ascending=sort_order == "ascending") if sort_order is not None else df
138
+
139
+ default_colors = get_plot_colors(len(value_col))
140
+
141
+ plot_kind = "bar" if orientation in ["vertical", "v"] else "barh"
142
+ color = kwargs.pop("color", default_colors)
143
+ legend = kwargs.pop("legend", (len(value_col) > 1))
144
+
145
+ ax = df.plot(
146
+ kind=plot_kind,
147
+ y=value_col,
148
+ x=x_col,
149
+ ax=ax,
150
+ width=width,
151
+ color=color,
152
+ legend=legend,
153
+ **kwargs,
154
+ )
155
+
156
+ ax = gu.standard_graph_styles(
157
+ ax=ax,
158
+ title=title,
159
+ x_label=x_label,
160
+ y_label=y_label,
161
+ legend_title=legend_title,
162
+ move_legend_outside=move_legend_outside,
163
+ )
164
+
165
+ if use_hatch:
166
+ ax = gu.apply_hatches(ax=ax, num_segments=len(value_col))
167
+
168
+ # Add data labels
169
+ if data_label_format:
170
+ _generate_bar_labels(
171
+ ax=ax,
172
+ plot_kind=plot_kind,
173
+ value_col=value_col,
174
+ df=df,
175
+ data_label_format=data_label_format,
176
+ x_col=x_col if x_col is not None else df.index,
177
+ is_stacked=kwargs.get("stacked", False),
178
+ num_digits=num_digits,
179
+ )
180
+
181
+ if source_text:
182
+ gu.add_source_text(ax=ax, source_text=source_text)
183
+
184
+ return gu.standard_tick_styles(ax=ax)
185
+
186
+
187
+ def _generate_bar_labels(
188
+ ax: Axes,
189
+ plot_kind: str,
190
+ value_col: list[str],
191
+ df: pd.DataFrame,
192
+ data_label_format: Literal[
193
+ "absolute",
194
+ "percentage",
195
+ "percentage_by_bar_group",
196
+ "percentage_by_series",
197
+ ],
198
+ x_col: str | pd.Index,
199
+ is_stacked: bool,
200
+ num_digits: int = 3,
201
+ ) -> None:
202
+ """Adds bar labels to the bar plot containers."""
203
+ division_by_zero_list = [] # A list to track occurrences of division by zero
204
+ all_bar_values = [] # To store all the bar values for decimal calculation
205
+ total_sum_per_column = df[value_col].sum() # Series with a total for each column
206
+ all_labels = []
207
+
208
+ for container, column in zip(ax.containers, value_col, strict=False):
209
+ if data_label_format == "absolute":
210
+ container_labels, bar_values = _generate_absolute_labels(container, plot_kind, num_digits)
211
+ all_bar_values.extend(bar_values)
212
+ elif data_label_format == "percentage_by_bar_group":
213
+ group_totals = df.groupby(x_col)[value_col].sum().sum(axis=1)
214
+ container_labels = _generate_percentage_by_bar_group_labels(
215
+ container,
216
+ group_totals,
217
+ plot_kind,
218
+ num_digits,
219
+ division_by_zero_list,
220
+ )
221
+ elif data_label_format == "percentage_by_series":
222
+ column_total = total_sum_per_column[column]
223
+ container_labels = _generate_percentage_by_series_labels(
224
+ container,
225
+ column_total,
226
+ plot_kind,
227
+ num_digits,
228
+ division_by_zero_list,
229
+ )
230
+
231
+ all_labels.append(container_labels)
232
+
233
+ for container, container_labels in zip(ax.containers, all_labels, strict=True):
234
+ _apply_labels_to_container(ax, container, container_labels, data_label_format, is_stacked)
235
+
236
+ # Check if any division by zero occurred and how many times
237
+ if division_by_zero_list:
238
+ division_count = len(division_by_zero_list)
239
+ warnings.warn(
240
+ f"Division by zero detected {division_count} time(s), will skip displaying percentages for certain bars.",
241
+ UserWarning,
242
+ stacklevel=2,
243
+ )
244
+
245
+
246
+ def _get_bar_value(v: Rectangle, plot_type: str) -> float:
247
+ """Retrieve the raw value from the bar/rectangle.
248
+
249
+ Args:
250
+ v (Rectangle): The bar/rectangle object.
251
+ plot_type (str): The type of plot ('bar' for vertical, 'barh' for horizontal).
252
+
253
+ Returns:
254
+ float: The value represented by the bar (height or width).
255
+ """
256
+ return v.get_height() if plot_type == "bar" else v.get_width()
257
+
258
+
259
+ def _generate_absolute_labels(
260
+ container: BarContainer,
261
+ plot_kind: str,
262
+ num_digits: int,
263
+ ) -> tuple[list[str], list[float]]:
264
+ """Generate absolute value labels for the bars.
265
+
266
+ Args:
267
+ container (BarContainer): The container holding the bar objects.
268
+ plot_kind (str): The type of plot ('bar' or 'barh').
269
+ num_digits (int): The number of digits to display in the labels.
270
+
271
+ Returns:
272
+ tuple[list[str], list[float]]: A list of formatted labels and a list of raw bar values.
273
+ """
274
+ bar_values = [_get_bar_value(v, plot_kind) for v in container]
275
+ labels = [
276
+ gu.truncate_to_x_digits(
277
+ num_str=gu.human_format(num=value, decimals=num_digits),
278
+ digits=num_digits,
279
+ )
280
+ for value in bar_values
281
+ ]
282
+ return labels, bar_values
283
+
284
+
285
+ def _generate_percentage_by_bar_group_labels(
286
+ container: BarContainer,
287
+ group_totals: pd.Series,
288
+ plot_kind: str,
289
+ num_digits: int,
290
+ division_by_zero_list: list,
291
+ ) -> list[str]:
292
+ """Generate percentage labels for each bar based on the group totals.
293
+
294
+ Args:
295
+ container (BarContainer): The container holding the bar objects.
296
+ group_totals (pd.Series): The total values for each group.
297
+ plot_kind (str): The type of plot ('bar' or 'barh').
298
+ num_digits (int): The number of digits to display in the labels.
299
+ division_by_zero_list (list): A list to track occurrences of division by zero.
300
+
301
+ Returns:
302
+ list[str]: A list of formatted percentage labels.
303
+ """
304
+ labels = []
305
+ for i, v in enumerate(container):
306
+ if group_totals.iloc[i] == 0:
307
+ division_by_zero_list.append(True) # Track division by zero
308
+ labels.append("")
309
+ else:
310
+ bar_value = _get_bar_value(v, plot_kind)
311
+ percentage_value = (bar_value / group_totals.iloc[i]) * 100
312
+ human_percentage_value: str = gu.human_format(num=percentage_value, decimals=num_digits)
313
+ truncated_value: str = gu.truncate_to_x_digits(num_str=human_percentage_value, digits=num_digits)
314
+ labels.append(truncated_value)
315
+ return labels
316
+
317
+
318
+ def _generate_percentage_by_series_labels(
319
+ container: BarContainer,
320
+ column_total: float,
321
+ plot_kind: str,
322
+ num_digits: int,
323
+ division_by_zero_list: list,
324
+ ) -> list[str]:
325
+ """Generate percentage labels for each bar based on the series totals.
326
+
327
+ Args:
328
+ container (BarContainer): The container holding the bar objects.
329
+ column_total (float): The total value of the series.
330
+ plot_kind (str): The type of plot ('bar' or 'barh').
331
+ num_digits (int): The number of digits to display in the labels.
332
+ division_by_zero_list (list): A list to track occurrences of division by zero.
333
+
334
+ Returns:
335
+ list[str]: A list of formatted percentage labels.
336
+ """
337
+ labels = []
338
+ for v in container:
339
+ if column_total == 0:
340
+ division_by_zero_list.append(True) # Track division by zero
341
+ labels.append("")
342
+ else:
343
+ bar_value = _get_bar_value(v, plot_kind)
344
+ percentage_value = (bar_value / column_total) * 100
345
+ human_percentage_value: str = gu.human_format(num=percentage_value, decimals=num_digits)
346
+ truncated_value: str = gu.truncate_to_x_digits(num_str=human_percentage_value, digits=num_digits)
347
+ labels.append(truncated_value)
348
+ return labels
349
+
350
+
351
+ def _apply_labels_to_container(
352
+ ax: Axes,
353
+ container: BarContainer,
354
+ container_labels: list[str],
355
+ data_label_format: str,
356
+ is_stacked: bool,
357
+ ) -> None:
358
+ """Apply the formatted labels to the bar container.
359
+
360
+ Args:
361
+ ax (Axes): The matplotlib axes object containing the plot.
362
+ container (BarContainer): The container holding the bar objects.
363
+ container_labels (list[str]): A list of formatted labels to apply.
364
+ data_label_format (str): The format of the labels (e.g., 'absolute', 'percentage').
365
+ is_stacked (bool): Whether the bars are stacked or not.
366
+
367
+ Returns:
368
+ None
369
+ """
370
+ formatted_labels = [f"{v}%" if v != "" and data_label_format != "absolute" else v for v in container_labels]
371
+ ax.bar_label(
372
+ container,
373
+ labels=formatted_labels,
374
+ label_type="center" if is_stacked else "edge",
375
+ )
@@ -0,0 +1,197 @@
1
+ """This module provides functionality for creating broken timeline plots from pandas DataFrames.
2
+
3
+ A broken timeline plot visualizes data availability across categories over time, showing periods where
4
+ data is available as horizontal bars, with gaps indicating missing data periods.
5
+
6
+ ### Features
7
+
8
+ - **Multiple Categories**: Support for displaying multiple categories with different colors
9
+ - **Customizable Periods**: Aggregate data by different time periods (daily, weekly)
10
+ - **Threshold Filtering**: Filter out values below a specified threshold
11
+ - **Date Formatting**: Uses matplotlib's ConciseDateFormatter for clean date axis labels
12
+
13
+ ### Use Cases
14
+
15
+ - **Data Quality Assessment**: Visualize data availability gaps across categories/segments over time
16
+ - **Product Availability Analysis**: Identify periods with stock outs by store/category
17
+ - **Seasonality Analysis**: Assess to look for period of low sales that may indicate seasonality or other trends
18
+ """
19
+
20
+ from typing import Any
21
+
22
+ import matplotlib.dates as mdates
23
+ import matplotlib.pyplot as plt
24
+ import numpy as np
25
+ import pandas as pd
26
+ from matplotlib.axes import Axes, SubplotBase
27
+
28
+ import openretailscience.plots.styles.graph_utils as gu
29
+ from openretailscience.options import get_option
30
+ from openretailscience.plots.styles.colors import get_named_color
31
+
32
+ # Period configurations: gap threshold and duration (in days)
33
+ PERIOD_CONFIG = {
34
+ "D": 1, # Daily: 1 day gap and duration
35
+ "W": 7, # Weekly: 1 week gap and duration
36
+ }
37
+
38
+
39
+ def _validate_inputs(df: pd.DataFrame, category_col: str, value_col: str, date_col: str, period: str) -> None:
40
+ """Validate input parameters for the plot function.
41
+
42
+ Args:
43
+ df: Input DataFrame
44
+ category_col: Category column name
45
+ value_col: Value column name
46
+ date_col: Date column name
47
+ period: Time period for aggregation
48
+
49
+ Raises:
50
+ ValueError: If DataFrame is empty or invalid period specified
51
+ KeyError: If required columns don't exist
52
+ """
53
+ if df.empty:
54
+ raise ValueError("Cannot plot with empty DataFrame")
55
+
56
+ # Validate required columns exist
57
+ required_cols = [date_col, category_col, value_col]
58
+
59
+ for col in required_cols:
60
+ if col not in df.columns:
61
+ msg = f"Required column '{col}' not found in DataFrame"
62
+ raise KeyError(msg)
63
+
64
+ # Validate period parameter
65
+ valid_periods = list(PERIOD_CONFIG.keys())
66
+ if period not in valid_periods:
67
+ msg = f"Invalid period '{period}'. Must be one of {valid_periods}."
68
+ raise ValueError(msg)
69
+
70
+
71
+ def plot(
72
+ df: pd.DataFrame,
73
+ category_col: str,
74
+ value_col: str,
75
+ title: str | None = None,
76
+ x_label: str | None = None,
77
+ y_label: str | None = None,
78
+ ax: Axes | None = None,
79
+ source_text: str | None = None,
80
+ period: str = "D",
81
+ agg_func: str = "sum",
82
+ threshold_value: float | None = None,
83
+ bar_height: float = 0.8,
84
+ figsize: tuple[int, int] | None = None,
85
+ **kwargs: dict[str, Any],
86
+ ) -> SubplotBase:
87
+ """Creates a broken timeline plot showing data availability across categories over time.
88
+
89
+ Shows periods where data is available as horizontal bars, with gaps indicating missing data periods.
90
+
91
+ Args:
92
+ df (pd.DataFrame): The input DataFrame containing the data to be plotted.
93
+ category_col (str): The column containing categories to display on y-axis.
94
+ value_col (str): The column containing values to determine data availability.
95
+ title (str, optional): The title of the plot. Defaults to None.
96
+ x_label (str, optional): The label for the x-axis. Defaults to None.
97
+ y_label (str, optional): The label for the y-axis. Defaults to None.
98
+ ax (Axes, optional): The Matplotlib Axes object to plot on. Defaults to None.
99
+ source_text (str, optional): Text to be displayed as a source at the bottom of the plot. Defaults to None.
100
+ period (str, optional): Period for aggregating data using pandas to_period ("D", "W").
101
+ Defaults to "D".
102
+ agg_func (str, optional): The aggregation function to apply to the value_col when grouping by period.
103
+ Defaults to "sum".
104
+ threshold_value (float, optional): Values below this threshold are considered gaps. Defaults to None.
105
+ bar_height (float, optional): Height of timeline bars as fraction of available space. Defaults to 0.8.
106
+ figsize: tuple[int, int] | None = None,
107
+ **kwargs (dict[str, Any]): Additional keyword arguments for matplotlib broken_barh function.
108
+
109
+ Returns:
110
+ SubplotBase: The Matplotlib Axes object with the generated plot.
111
+
112
+ Raises:
113
+ ValueError: If DataFrame is empty, required columns are missing, or invalid period specified.
114
+ KeyError: If specified columns don't exist in the DataFrame.
115
+ """
116
+ date_col = get_option("column.transaction_date")
117
+
118
+ # Convert period to uppercase to handle lowercase inputs
119
+ period = period.upper()
120
+
121
+ # Validate required columns exist
122
+ _validate_inputs(df, category_col, value_col, date_col, period)
123
+
124
+ # Create a copy of the data and ensure date column is datetime
125
+ df_copy = df.copy()
126
+ df_copy[date_col] = pd.to_datetime(df_copy[date_col])
127
+
128
+ # Apply threshold filter if specified
129
+ if threshold_value is not None:
130
+ df_copy = df_copy[df_copy[value_col] >= threshold_value]
131
+
132
+ df_copy["period"] = df_copy[date_col].dt.to_period(period)
133
+ df_copy = df_copy.groupby([category_col, "period"]).agg({value_col: agg_func}).reset_index()
134
+ df_copy[date_col] = df_copy["period"].dt.start_time
135
+
136
+ # Sort by date once for all categories
137
+ df_copy = df_copy.sort_values(date_col)
138
+
139
+ # Get unique categories and create y-axis mapping
140
+ categories = sorted(df_copy[category_col].unique())
141
+ category_to_y = {cat: i for i, cat in enumerate(categories)}
142
+
143
+ if ax is None:
144
+ _, ax = plt.subplots(figsize=figsize)
145
+
146
+ # Use module-level period configuration
147
+ gap_threshold = PERIOD_CONFIG[period]
148
+ bar_color = kwargs.pop("color", get_named_color("primary"))
149
+
150
+ # Process each category
151
+ for category in categories:
152
+ dates = df_copy[df_copy[category_col] == category][date_col].to_numpy()
153
+
154
+ # Convert to matplotlib date numbers and find segments
155
+ dates_num = mdates.date2num(dates)
156
+ gaps = np.diff(dates_num) > gap_threshold
157
+ date_segments = np.split(dates_num, np.where(gaps)[0] + 1)
158
+
159
+ # Calculate appropriate width based on period type
160
+ base_width = PERIOD_CONFIG[period]
161
+ segments = []
162
+ for seg in date_segments:
163
+ if len(seg) > 0:
164
+ # Width should be number of periods * typical period duration
165
+ width = len(seg) * base_width
166
+ segments.append((seg[0], width))
167
+ bar_offset = bar_height / 2
168
+ ax.broken_barh(
169
+ segments,
170
+ (category_to_y[category] - bar_offset, bar_height),
171
+ facecolors=bar_color,
172
+ **kwargs,
173
+ )
174
+
175
+ # Configure y-axis
176
+ ax.set_yticks(range(len(categories)))
177
+ ax.set_yticklabels(categories)
178
+ ax.invert_yaxis()
179
+
180
+ # Configure x-axis for dates
181
+ ax.xaxis_date()
182
+ ax.xaxis.set_major_locator(mdates.AutoDateLocator())
183
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()))
184
+
185
+ # Apply standard graph styles
186
+ ax = gu.standard_graph_styles(
187
+ ax=ax,
188
+ title=title,
189
+ x_label=x_label,
190
+ y_label=y_label,
191
+ )
192
+
193
+ # Add source text if provided
194
+ if source_text:
195
+ gu.add_source_text(ax=ax, source_text=source_text)
196
+
197
+ return gu.standard_tick_styles(ax=ax)