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