openretailscience 0.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openretailscience/__init__.py +3 -0
- openretailscience/analysis/__init__.py +0 -0
- openretailscience/analysis/cohort.py +185 -0
- openretailscience/analysis/composite_rank.py +372 -0
- openretailscience/analysis/cross_shop.py +354 -0
- openretailscience/analysis/customer.py +459 -0
- openretailscience/analysis/customer_decision_hierarchy.py +359 -0
- openretailscience/analysis/gain_loss.py +340 -0
- openretailscience/analysis/haversine.py +50 -0
- openretailscience/analysis/product_association.py +418 -0
- openretailscience/analysis/revenue_tree.py +553 -0
- openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
- openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
- openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
- openretailscience/constants.py +295 -0
- openretailscience/metrics/__init__.py +0 -0
- openretailscience/metrics/base.py +29 -0
- openretailscience/metrics/distribution/__init__.py +0 -0
- openretailscience/metrics/distribution/acv.py +81 -0
- openretailscience/metrics/distribution/pct_of_stores.py +118 -0
- openretailscience/options.py +842 -0
- openretailscience/plots/__init__.py +0 -0
- openretailscience/plots/area.py +114 -0
- openretailscience/plots/bar.py +375 -0
- openretailscience/plots/broken_timeline.py +197 -0
- openretailscience/plots/cohort.py +94 -0
- openretailscience/plots/heatmap.py +134 -0
- openretailscience/plots/histogram.py +276 -0
- openretailscience/plots/index.py +505 -0
- openretailscience/plots/line.py +379 -0
- openretailscience/plots/period_on_period.py +144 -0
- openretailscience/plots/price.py +275 -0
- openretailscience/plots/scatter.py +382 -0
- openretailscience/plots/styles/__init__.py +1 -0
- openretailscience/plots/styles/colors.py +215 -0
- openretailscience/plots/styles/font_utils.py +62 -0
- openretailscience/plots/styles/graph_utils.py +834 -0
- openretailscience/plots/styles/styling_helpers.py +155 -0
- openretailscience/plots/time.py +160 -0
- openretailscience/plots/tree_diagram.py +813 -0
- openretailscience/plots/venn.py +127 -0
- openretailscience/plots/waterfall.py +217 -0
- openretailscience/plugin.py +238 -0
- openretailscience/segmentation/__init__.py +0 -0
- openretailscience/segmentation/hml.py +97 -0
- openretailscience/segmentation/nlr.py +218 -0
- openretailscience/segmentation/rfm.py +307 -0
- openretailscience/segmentation/segstats.py +1177 -0
- openretailscience/segmentation/threshold.py +165 -0
- openretailscience/utils/__init__.py +0 -0
- openretailscience/utils/date.py +226 -0
- openretailscience/utils/filter_and_label.py +52 -0
- openretailscience/utils/label.py +79 -0
- openretailscience/utils/validation.py +45 -0
- openretailscience-0.45.0.dist-info/METADATA +364 -0
- openretailscience-0.45.0.dist-info/RECORD +60 -0
- openretailscience-0.45.0.dist-info/WHEEL +4 -0
- openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""This module provides flexible functionality for creating line plots from pandas DataFrames.
|
|
2
|
+
|
|
3
|
+
It focuses on visualizing sequences that are ordered or sequential but not necessarily categorical, such as "days since
|
|
4
|
+
an event" or "months since a competitor opened." However, while this module can handle datetime values on the x-axis,
|
|
5
|
+
the **plots.time_line** module has additional features that make working with datetimes easier,
|
|
6
|
+
such as easily resampling the data to alternate time frames.
|
|
7
|
+
|
|
8
|
+
The sequences used in this module can include values like "days since an event" (e.g.,
|
|
9
|
+
-2, -1, 0, 1, 2) or "months since a competitor store opened." **This module is not intended
|
|
10
|
+
for use with actual datetime values**.
|
|
11
|
+
|
|
12
|
+
### Core Features
|
|
13
|
+
|
|
14
|
+
- **Plotting Sequences or Indexes**: Plot one or more value columns (**`value_col`**) with
|
|
15
|
+
support for sequences like -2, -1, 0, 1, 2 (e.g., months since an event), using either the
|
|
16
|
+
index or a specified x-axis column (**`x_col`**).
|
|
17
|
+
- **Custom X-Axis or Index**: Use any column as the x-axis (**`x_col`**) or plot based on the
|
|
18
|
+
index if no x-axis column is specified.
|
|
19
|
+
- **Multiple Lines**: Create separate lines for each unique value in **`group_col`** (e.g.,
|
|
20
|
+
categories or product types).
|
|
21
|
+
- **Comprehensive Customization**: Easily customize plot titles, axis labels, and legends, with
|
|
22
|
+
the option to move the legend outside the plot.
|
|
23
|
+
- **Pre-Aggregated Data**: The data must be pre-aggregated before plotting, as no aggregation
|
|
24
|
+
occurs within the module.
|
|
25
|
+
|
|
26
|
+
### Use Cases
|
|
27
|
+
|
|
28
|
+
- **Daily Trends**: Plot trends such as daily revenue or user activity, for example, tracking
|
|
29
|
+
revenue since the start of the year.
|
|
30
|
+
- **Event Impact**: Visualize how metrics (e.g., revenue, sales, or traffic) change before and
|
|
31
|
+
after an important event, such as a competitor store opening or a product launch.
|
|
32
|
+
- **Category Comparison**: Compare metrics across multiple categories over time, for example,
|
|
33
|
+
tracking total revenue for the top categories before and after an event like the introduction
|
|
34
|
+
of a new competitor.
|
|
35
|
+
|
|
36
|
+
### Limitations and Handling of Temporal Data
|
|
37
|
+
|
|
38
|
+
- **Limited Handling of Temporal Data**: This module can plot simple time-based sequences, such
|
|
39
|
+
as "days since an event," but it cannot manipulate or directly handle datetime or date-like
|
|
40
|
+
columns. It is not optimized for actual datetime values. If a datetime column is passed or more
|
|
41
|
+
complex temporal plotting is needed, consider using the **`plots.time_line`** module, which is
|
|
42
|
+
specifically designed for working with temporal data and performing time-based manipulation.
|
|
43
|
+
- **Pre-Aggregated Data Required**: The module does not perform any data aggregation, so all
|
|
44
|
+
data must be pre-aggregated before being passed in for plotting.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import pandas as pd
|
|
49
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
50
|
+
|
|
51
|
+
import openretailscience.plots.styles.graph_utils as gu
|
|
52
|
+
from openretailscience.plots.styles.colors import get_named_color, get_plot_colors
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_and_prepare_input(
|
|
56
|
+
df: pd.DataFrame | pd.Series,
|
|
57
|
+
value_col: str | list[str] | None,
|
|
58
|
+
x_col: str | None,
|
|
59
|
+
group_col: str | None,
|
|
60
|
+
) -> tuple[pd.DataFrame, str | list[str]]:
|
|
61
|
+
"""Validate input parameters and convert Series to DataFrame if needed."""
|
|
62
|
+
# Handle Series input
|
|
63
|
+
if isinstance(df, pd.Series):
|
|
64
|
+
if value_col is not None:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"When df is a pd.Series, value_col must be None. The Series itself represents the values to plot.",
|
|
67
|
+
)
|
|
68
|
+
if x_col is not None:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"When df is a pd.Series, x_col must be None. The Series index is used as the x-axis.",
|
|
71
|
+
)
|
|
72
|
+
if group_col is not None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"When df is a pd.Series, group_col must be None. Cannot group a single series.",
|
|
75
|
+
)
|
|
76
|
+
# Convert Series to DataFrame for uniform processing
|
|
77
|
+
series_name = df.name if df.name is not None else "value"
|
|
78
|
+
df = df.to_frame(name=series_name)
|
|
79
|
+
value_col = series_name
|
|
80
|
+
|
|
81
|
+
# Validate value_col for DataFrame input
|
|
82
|
+
if value_col is None:
|
|
83
|
+
raise ValueError("value_col is required when df is a DataFrame")
|
|
84
|
+
|
|
85
|
+
if isinstance(value_col, list) and group_col:
|
|
86
|
+
raise ValueError("Cannot use both a list for `value_col` and a `group_col`. Choose one.")
|
|
87
|
+
|
|
88
|
+
return df, value_col
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_highlight_parameter(
|
|
92
|
+
highlight: str | list[str] | None,
|
|
93
|
+
value_col: str | list[str],
|
|
94
|
+
group_col: str | None,
|
|
95
|
+
pivot_df: pd.DataFrame | None = None,
|
|
96
|
+
) -> list[str] | None:
|
|
97
|
+
"""Validate and normalize the highlight parameter against available columns."""
|
|
98
|
+
if highlight is None:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# Convert single string to list for uniform handling
|
|
102
|
+
if isinstance(highlight, str):
|
|
103
|
+
highlight = [highlight]
|
|
104
|
+
|
|
105
|
+
# Check if this is a single-line plot
|
|
106
|
+
is_single_line = group_col is None and (
|
|
107
|
+
isinstance(value_col, str) or (isinstance(value_col, list) and len(value_col) == 1)
|
|
108
|
+
)
|
|
109
|
+
if is_single_line:
|
|
110
|
+
raise ValueError("highlight parameter cannot be used with single-line plots")
|
|
111
|
+
|
|
112
|
+
# Validate highlight values against available columns if pivot_df is provided
|
|
113
|
+
if pivot_df is not None:
|
|
114
|
+
available_columns = list(pivot_df.columns)
|
|
115
|
+
invalid_highlights = [h for h in highlight if h not in available_columns]
|
|
116
|
+
if invalid_highlights:
|
|
117
|
+
error_msg = f"highlight values {invalid_highlights} not found in available columns {available_columns}"
|
|
118
|
+
raise ValueError(error_msg)
|
|
119
|
+
|
|
120
|
+
return highlight
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _create_pivot_dataframe(
|
|
124
|
+
df: pd.DataFrame,
|
|
125
|
+
value_col: str | list[str],
|
|
126
|
+
x_col: str | None,
|
|
127
|
+
group_col: str | None,
|
|
128
|
+
fill_na_value: float | None,
|
|
129
|
+
) -> pd.DataFrame:
|
|
130
|
+
"""Create pivot DataFrame for plotting."""
|
|
131
|
+
if group_col is None:
|
|
132
|
+
pivot_df = df.set_index(x_col if x_col is not None else df.index)[
|
|
133
|
+
[value_col] if isinstance(value_col, str) else value_col
|
|
134
|
+
]
|
|
135
|
+
else:
|
|
136
|
+
pivot_df = (
|
|
137
|
+
df.pivot(columns=group_col, values=value_col)
|
|
138
|
+
if x_col is None
|
|
139
|
+
else df.pivot(index=x_col, columns=group_col, values=value_col)
|
|
140
|
+
)
|
|
141
|
+
if fill_na_value is not None:
|
|
142
|
+
pivot_df = pivot_df.fillna(fill_na_value)
|
|
143
|
+
|
|
144
|
+
return pivot_df
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _categorize_columns(
|
|
148
|
+
pivot_df: pd.DataFrame,
|
|
149
|
+
highlight: list[str] | None,
|
|
150
|
+
) -> tuple[list[str], list[str]]:
|
|
151
|
+
"""Categorize columns into highlighted and context groups."""
|
|
152
|
+
if highlight is not None:
|
|
153
|
+
highlighted_cols = [col for col in pivot_df.columns if col in highlight]
|
|
154
|
+
context_cols = [col for col in pivot_df.columns if col not in highlight]
|
|
155
|
+
else:
|
|
156
|
+
highlighted_cols = list(pivot_df.columns)
|
|
157
|
+
context_cols = []
|
|
158
|
+
|
|
159
|
+
return highlighted_cols, context_cols
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _generate_colors(highlighted_cols: list[str]) -> list[str]:
|
|
163
|
+
"""Generate colors for highlighted lines."""
|
|
164
|
+
num_highlighted = len(highlighted_cols) if highlighted_cols else 1
|
|
165
|
+
return get_plot_colors(num_highlighted)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_plot(
|
|
169
|
+
pivot_df: pd.DataFrame,
|
|
170
|
+
highlighted_cols: list[str],
|
|
171
|
+
context_cols: list[str],
|
|
172
|
+
highlighted_colors: list[str],
|
|
173
|
+
is_multi_line: bool,
|
|
174
|
+
ax: Axes | None,
|
|
175
|
+
**kwargs: dict[str, any],
|
|
176
|
+
) -> Axes:
|
|
177
|
+
"""Render the actual plot with context and highlighted lines."""
|
|
178
|
+
# Context line styling
|
|
179
|
+
context_color = get_named_color("context")
|
|
180
|
+
context_alpha = 0.6
|
|
181
|
+
context_linewidth = 1.5
|
|
182
|
+
|
|
183
|
+
# Highlighted line styling
|
|
184
|
+
highlighted_linewidth = kwargs.pop("linewidth", 3)
|
|
185
|
+
highlighted_alpha = 1.0
|
|
186
|
+
|
|
187
|
+
# Plot context lines first (lower z-order)
|
|
188
|
+
if context_cols:
|
|
189
|
+
context_df = pivot_df[context_cols]
|
|
190
|
+
ax = context_df.plot(
|
|
191
|
+
ax=ax,
|
|
192
|
+
linewidth=context_linewidth,
|
|
193
|
+
color=context_color,
|
|
194
|
+
alpha=context_alpha,
|
|
195
|
+
legend=False,
|
|
196
|
+
zorder=1,
|
|
197
|
+
**{k: v for k, v in kwargs.items() if k not in ["color", "alpha", "zorder"]},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Plot highlighted lines second (higher z-order)
|
|
201
|
+
if highlighted_cols:
|
|
202
|
+
highlighted_df = pivot_df[highlighted_cols]
|
|
203
|
+
ax = highlighted_df.plot(
|
|
204
|
+
ax=ax,
|
|
205
|
+
linewidth=highlighted_linewidth,
|
|
206
|
+
color=kwargs.pop("color", highlighted_colors),
|
|
207
|
+
alpha=highlighted_alpha,
|
|
208
|
+
legend=is_multi_line,
|
|
209
|
+
zorder=2,
|
|
210
|
+
**kwargs,
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
# Handle case where only context lines exist (shouldn't happen due to validation)
|
|
214
|
+
ax = pivot_df.plot(
|
|
215
|
+
ax=ax,
|
|
216
|
+
linewidth=highlighted_linewidth,
|
|
217
|
+
color=kwargs.pop("color", highlighted_colors),
|
|
218
|
+
alpha=highlighted_alpha,
|
|
219
|
+
legend=is_multi_line,
|
|
220
|
+
**kwargs,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return ax
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _apply_final_styling(
|
|
227
|
+
ax: Axes,
|
|
228
|
+
title: str | None,
|
|
229
|
+
x_label: str | None,
|
|
230
|
+
y_label: str | None,
|
|
231
|
+
legend_title: str | None,
|
|
232
|
+
move_legend_outside: bool,
|
|
233
|
+
source_text: str | None,
|
|
234
|
+
) -> SubplotBase:
|
|
235
|
+
"""Apply final styling and formatting to the plot."""
|
|
236
|
+
ax = gu.standard_graph_styles(
|
|
237
|
+
ax=ax,
|
|
238
|
+
title=title,
|
|
239
|
+
x_label=x_label,
|
|
240
|
+
y_label=y_label,
|
|
241
|
+
legend_title=legend_title,
|
|
242
|
+
move_legend_outside=move_legend_outside,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if source_text is not None:
|
|
246
|
+
gu.add_source_text(ax=ax, source_text=source_text)
|
|
247
|
+
|
|
248
|
+
return gu.standard_tick_styles(ax)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def plot(
|
|
252
|
+
df: pd.DataFrame | pd.Series,
|
|
253
|
+
value_col: str | list[str] | None = None,
|
|
254
|
+
x_label: str | None = None,
|
|
255
|
+
y_label: str | None = None,
|
|
256
|
+
title: str | None = None,
|
|
257
|
+
x_col: str | None = None,
|
|
258
|
+
group_col: str | None = None,
|
|
259
|
+
ax: Axes | None = None,
|
|
260
|
+
source_text: str | None = None,
|
|
261
|
+
legend_title: str | None = None,
|
|
262
|
+
move_legend_outside: bool = False,
|
|
263
|
+
fill_na_value: float | None = None,
|
|
264
|
+
highlight: str | list[str] | None = None,
|
|
265
|
+
**kwargs: dict[str, any],
|
|
266
|
+
) -> SubplotBase:
|
|
267
|
+
"""Plots `value_col` over `x_col` or index, with a separate line per unique `group_col` value.
|
|
268
|
+
|
|
269
|
+
This function supports both pandas DataFrames and Series as input. When a Series is provided,
|
|
270
|
+
the Series values are plotted against its index, and `value_col` must be None.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
df (pd.DataFrame | pd.Series): The dataframe or series to plot. When a Series is provided,
|
|
274
|
+
it represents the values to plot against its index.
|
|
275
|
+
value_col (str | list[str], optional): The column(s) to plot. Must be None when df is a Series.
|
|
276
|
+
Required when df is a DataFrame.
|
|
277
|
+
x_label (str, optional): The x-axis label.
|
|
278
|
+
y_label (str, optional): The y-axis label.
|
|
279
|
+
title (str, optional): The title of the plot.
|
|
280
|
+
x_col (str, optional): The column to be used as the x-axis. If None, the index is used.
|
|
281
|
+
group_col (str, optional): The column used to define different lines.
|
|
282
|
+
legend_title (str, optional): The title of the legend.
|
|
283
|
+
ax (Axes, optional): Matplotlib axes object to plot on.
|
|
284
|
+
source_text (str, optional): The source text to add to the plot.
|
|
285
|
+
move_legend_outside (bool, optional): Move the legend outside the plot.
|
|
286
|
+
fill_na_value (float, optional): Value to fill NaNs with after pivoting.
|
|
287
|
+
highlight (str | list[str], optional): Line(s) to highlight. When using
|
|
288
|
+
`group_col`, these should be group values. When using a list of `value_col`,
|
|
289
|
+
these should be column names. Highlighted lines will be rendered with bold
|
|
290
|
+
linewidth (3), full opacity (alpha=1.0), and saturated colors. Non-highlighted
|
|
291
|
+
lines will be muted with gray color (#9ca3af), reduced opacity (alpha=0.6),
|
|
292
|
+
thinner linewidth (1.5), and rendered behind highlighted lines.
|
|
293
|
+
**kwargs: Additional keyword arguments for Pandas' `plot` function.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
SubplotBase: The matplotlib axes object.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
ValueError: If `value_col` is a list and `group_col` is provided (which causes ambiguity in plotting).
|
|
300
|
+
ValueError: If df is a Series and `value_col` is not None.
|
|
301
|
+
ValueError: If df is a DataFrame and `value_col` is None.
|
|
302
|
+
ValueError: If df is a Series and `x_col` is specified (Series uses its index as x-axis).
|
|
303
|
+
ValueError: If df is a Series and `group_col` is specified (cannot group a single series).
|
|
304
|
+
ValueError: If `highlight` is provided for single-line plot.
|
|
305
|
+
ValueError: If `highlight` values don't match available groups/columns.
|
|
306
|
+
|
|
307
|
+
Examples:
|
|
308
|
+
Highlighting specific product categories:
|
|
309
|
+
|
|
310
|
+
>>> import pandas as pd
|
|
311
|
+
>>> from openretailscience.plots import line
|
|
312
|
+
>>> df = pd.DataFrame({
|
|
313
|
+
... "month": [1, 2, 3, 1, 2, 3, 1, 2, 3],
|
|
314
|
+
... "category": ["Electronics", "Electronics", "Electronics",
|
|
315
|
+
... "Clothing", "Clothing", "Clothing",
|
|
316
|
+
... "Home", "Home", "Home"],
|
|
317
|
+
... "revenue": [100, 120, 140, 80, 85, 90, 60, 65, 70]
|
|
318
|
+
... })
|
|
319
|
+
>>> line.plot(
|
|
320
|
+
... df=df,
|
|
321
|
+
... x_col="month",
|
|
322
|
+
... value_col="revenue",
|
|
323
|
+
... group_col="category",
|
|
324
|
+
... highlight=["Electronics", "Clothing"], # Home will be muted
|
|
325
|
+
... title="Revenue by Category (Electronics & Clothing Highlighted)"
|
|
326
|
+
... )
|
|
327
|
+
|
|
328
|
+
Highlighting specific value columns:
|
|
329
|
+
|
|
330
|
+
>>> df = pd.DataFrame({
|
|
331
|
+
... "day": range(1, 6),
|
|
332
|
+
... "revenue": [100, 110, 120, 130, 140],
|
|
333
|
+
... "units_sold": [50, 55, 60, 65, 70],
|
|
334
|
+
... "avg_order_value": [2.0, 2.0, 2.0, 2.0, 2.0],
|
|
335
|
+
... "profit_margin": [0.2, 0.22, 0.24, 0.26, 0.28]
|
|
336
|
+
... })
|
|
337
|
+
>>> line.plot(
|
|
338
|
+
... df=df,
|
|
339
|
+
... x_col="day",
|
|
340
|
+
... value_col=["revenue", "units_sold", "avg_order_value", "profit_margin"],
|
|
341
|
+
... highlight=["revenue", "profit_margin"], # Other metrics muted
|
|
342
|
+
... title="Daily Metrics (Revenue & Profit Margin Highlighted)"
|
|
343
|
+
... )
|
|
344
|
+
|
|
345
|
+
Single highlighted line:
|
|
346
|
+
|
|
347
|
+
>>> line.plot(
|
|
348
|
+
... df=df,
|
|
349
|
+
... x_col="month",
|
|
350
|
+
... value_col="revenue",
|
|
351
|
+
... group_col="category",
|
|
352
|
+
... highlight="Electronics", # str is acceptable for single highlight
|
|
353
|
+
... title="Revenue by Category (Electronics Highlighted)"
|
|
354
|
+
... )
|
|
355
|
+
"""
|
|
356
|
+
# Validate and prepare input
|
|
357
|
+
df, value_col = _validate_and_prepare_input(df, value_col, x_col, group_col)
|
|
358
|
+
|
|
359
|
+
# Validate highlight parameter (initial validation)
|
|
360
|
+
highlight = _validate_highlight_parameter(highlight, value_col, group_col)
|
|
361
|
+
|
|
362
|
+
# Create pivot DataFrame
|
|
363
|
+
pivot_df = _create_pivot_dataframe(df, value_col, x_col, group_col, fill_na_value)
|
|
364
|
+
|
|
365
|
+
# Validate highlight values against available columns
|
|
366
|
+
highlight = _validate_highlight_parameter(highlight, value_col, group_col, pivot_df)
|
|
367
|
+
|
|
368
|
+
# Categorize columns and generate colors
|
|
369
|
+
highlighted_cols, context_cols = _categorize_columns(pivot_df, highlight)
|
|
370
|
+
highlighted_colors = _generate_colors(highlighted_cols)
|
|
371
|
+
|
|
372
|
+
# Determine if multi-line plot
|
|
373
|
+
is_multi_line = (group_col is not None) or (isinstance(value_col, list) and len(value_col) > 1)
|
|
374
|
+
|
|
375
|
+
# Render the plot
|
|
376
|
+
ax = _render_plot(pivot_df, highlighted_cols, context_cols, highlighted_colors, is_multi_line, ax, **kwargs)
|
|
377
|
+
|
|
378
|
+
# Apply final styling
|
|
379
|
+
return _apply_final_styling(ax, title, x_label, y_label, legend_title, move_legend_outside, source_text)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Period on period module.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for plotting multiple overlapping time periods
|
|
4
|
+
from the same time series on a single line chart using matplotlib.
|
|
5
|
+
|
|
6
|
+
The `plot` function is useful for visual comparisons of temporal trends
|
|
7
|
+
across different time windows, with each time window plotted as a separate line
|
|
8
|
+
but aligned to a common starting point.
|
|
9
|
+
|
|
10
|
+
Example use case: Comparing sales data across multiple promotional weeks or seasonal periods.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from dateutil.relativedelta import relativedelta
|
|
18
|
+
from matplotlib.axes import Axes
|
|
19
|
+
|
|
20
|
+
import openretailscience.plots.styles.graph_utils as gu
|
|
21
|
+
from openretailscience.plots.line import plot as line_plot
|
|
22
|
+
|
|
23
|
+
LINE_STYLES = [
|
|
24
|
+
"-", # solid
|
|
25
|
+
"--", # dashed
|
|
26
|
+
":", # dotted
|
|
27
|
+
"-.", # dashdot
|
|
28
|
+
(0, (5, 10)), # long dash with offset
|
|
29
|
+
(0, (6, 6)), # loosely dashed
|
|
30
|
+
(0, (3, 5, 1, 5)), # loosely dashdotted
|
|
31
|
+
(0, (3, 5, 1, 5, 1, 5)), # loosely dashdotdotted
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plot(
|
|
36
|
+
df: pd.DataFrame,
|
|
37
|
+
x_col: str,
|
|
38
|
+
value_col: str,
|
|
39
|
+
periods: list[tuple[str | datetime, str | datetime]],
|
|
40
|
+
x_label: str | None = None,
|
|
41
|
+
y_label: str | None = None,
|
|
42
|
+
title: str | None = None,
|
|
43
|
+
source_text: str | None = None,
|
|
44
|
+
legend_title: str | None = None,
|
|
45
|
+
move_legend_outside: bool = False,
|
|
46
|
+
ax: Axes | None = None,
|
|
47
|
+
**kwargs: dict[str, any],
|
|
48
|
+
) -> Axes:
|
|
49
|
+
"""Plot multiple overlapping periods from a single time series as individual lines.
|
|
50
|
+
|
|
51
|
+
This function is used to align and overlay several time intervals from the same
|
|
52
|
+
dataset to facilitate visual comparison. Each period is realigned to the reference
|
|
53
|
+
start date and plotted as a separate line using a distinct linestyle.
|
|
54
|
+
|
|
55
|
+
Note:
|
|
56
|
+
The `periods` argument accepts a list of (start_date, end_date) tuples,
|
|
57
|
+
which define the time windows to overlay. Each element in the tuple can be either
|
|
58
|
+
a string (e.g., "2022-01-01") or a `datetime` object. You can use
|
|
59
|
+
`find_overlapping_periods` from `openretailscience.utils.date` to generate
|
|
60
|
+
the `periods` input automatically.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
df (pd.DataFrame): Input DataFrame containing the time series data.
|
|
64
|
+
x_col (str): Name of the column representing datetime values.
|
|
65
|
+
value_col (str): Name of the column representing the y-axis values (e.g. sales, counts).
|
|
66
|
+
periods (List[Tuple[Union[str, datetime], Union[str, datetime]]]):
|
|
67
|
+
A list of (start_date, end_date) tuples representing the periods to plot.
|
|
68
|
+
x_label (Optional[str]): Custom label for the x-axis.
|
|
69
|
+
y_label (Optional[str]): Custom label for the y-axis.
|
|
70
|
+
title (Optional[str]): Title for the plot.
|
|
71
|
+
source_text (Optional[str]): Text to show below the plot as a data source.
|
|
72
|
+
legend_title (Optional[str]): Title for the plot legend.
|
|
73
|
+
move_legend_outside (bool): Whether to place the legend outside the plot area.
|
|
74
|
+
ax (Optional[Axes]): Matplotlib Axes object to draw on. If None, a new one is created.
|
|
75
|
+
**kwargs: Additional keyword arguments passed to the base line plot function.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
matplotlib.axes.Axes: The matplotlib Axes object with the completed plot.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: The 'periods' list must contain at least two (start, end) tuples for comparison.
|
|
82
|
+
"""
|
|
83
|
+
min_period_length = 2
|
|
84
|
+
if len(periods) < min_period_length:
|
|
85
|
+
raise ValueError("The 'periods' list must contain at least two (start, end) tuples for comparison")
|
|
86
|
+
|
|
87
|
+
periods = [(pd.to_datetime(start), pd.to_datetime(end)) for start, end in periods]
|
|
88
|
+
start_ref = periods[0][0]
|
|
89
|
+
|
|
90
|
+
sorted_periods = sorted(periods, reverse=True, key=lambda x: pd.to_datetime(x[0]))
|
|
91
|
+
|
|
92
|
+
ax = ax or plt.gca()
|
|
93
|
+
|
|
94
|
+
period_styles = {period: LINE_STYLES[idx % len(LINE_STYLES)] for idx, period in enumerate(sorted_periods)}
|
|
95
|
+
|
|
96
|
+
df[x_col] = pd.to_datetime(df[x_col])
|
|
97
|
+
|
|
98
|
+
start_ref_year = start_ref.year
|
|
99
|
+
|
|
100
|
+
for start_str, end_str in periods:
|
|
101
|
+
style = period_styles[(start_str, end_str)]
|
|
102
|
+
start = pd.to_datetime(start_str)
|
|
103
|
+
end = pd.to_datetime(end_str)
|
|
104
|
+
period_df = df[(df[x_col] >= start) & (df[x_col] <= end)].copy()
|
|
105
|
+
|
|
106
|
+
if period_df.empty:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
year_diff = start.year - start_ref_year
|
|
110
|
+
|
|
111
|
+
period_df["realigned_date"] = period_df[x_col].apply(
|
|
112
|
+
lambda d, year_diff=year_diff: d - relativedelta(years=year_diff),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
label = f"{start_str.date()} to {end_str.date()}"
|
|
116
|
+
line_plot(
|
|
117
|
+
df=period_df,
|
|
118
|
+
x_col="realigned_date",
|
|
119
|
+
value_col=value_col,
|
|
120
|
+
ax=ax,
|
|
121
|
+
linestyle=style,
|
|
122
|
+
x_label=x_label,
|
|
123
|
+
y_label=y_label,
|
|
124
|
+
**kwargs,
|
|
125
|
+
)
|
|
126
|
+
line = ax.get_lines()[-1]
|
|
127
|
+
line.set_label(label)
|
|
128
|
+
line.set_linestyle(style)
|
|
129
|
+
|
|
130
|
+
ax = gu.standard_graph_styles(
|
|
131
|
+
ax=ax,
|
|
132
|
+
title=title,
|
|
133
|
+
x_label=x_label or x_col,
|
|
134
|
+
y_label=y_label or value_col,
|
|
135
|
+
legend_title=legend_title,
|
|
136
|
+
move_legend_outside=move_legend_outside,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
ax = gu.standard_tick_styles(ax)
|
|
140
|
+
|
|
141
|
+
if source_text:
|
|
142
|
+
gu.add_source_text(ax=ax, source_text=source_text)
|
|
143
|
+
|
|
144
|
+
return ax
|