openretailscience 0.45.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. openretailscience/__init__.py +3 -0
  2. openretailscience/analysis/__init__.py +0 -0
  3. openretailscience/analysis/cohort.py +185 -0
  4. openretailscience/analysis/composite_rank.py +372 -0
  5. openretailscience/analysis/cross_shop.py +354 -0
  6. openretailscience/analysis/customer.py +459 -0
  7. openretailscience/analysis/customer_decision_hierarchy.py +359 -0
  8. openretailscience/analysis/gain_loss.py +340 -0
  9. openretailscience/analysis/haversine.py +50 -0
  10. openretailscience/analysis/product_association.py +418 -0
  11. openretailscience/analysis/revenue_tree.py +553 -0
  12. openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
  13. openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
  14. openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
  15. openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
  16. openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
  17. openretailscience/constants.py +295 -0
  18. openretailscience/metrics/__init__.py +0 -0
  19. openretailscience/metrics/base.py +29 -0
  20. openretailscience/metrics/distribution/__init__.py +0 -0
  21. openretailscience/metrics/distribution/acv.py +81 -0
  22. openretailscience/metrics/distribution/pct_of_stores.py +118 -0
  23. openretailscience/options.py +842 -0
  24. openretailscience/plots/__init__.py +0 -0
  25. openretailscience/plots/area.py +114 -0
  26. openretailscience/plots/bar.py +375 -0
  27. openretailscience/plots/broken_timeline.py +197 -0
  28. openretailscience/plots/cohort.py +94 -0
  29. openretailscience/plots/heatmap.py +134 -0
  30. openretailscience/plots/histogram.py +276 -0
  31. openretailscience/plots/index.py +505 -0
  32. openretailscience/plots/line.py +379 -0
  33. openretailscience/plots/period_on_period.py +144 -0
  34. openretailscience/plots/price.py +275 -0
  35. openretailscience/plots/scatter.py +382 -0
  36. openretailscience/plots/styles/__init__.py +1 -0
  37. openretailscience/plots/styles/colors.py +215 -0
  38. openretailscience/plots/styles/font_utils.py +62 -0
  39. openretailscience/plots/styles/graph_utils.py +834 -0
  40. openretailscience/plots/styles/styling_helpers.py +155 -0
  41. openretailscience/plots/time.py +160 -0
  42. openretailscience/plots/tree_diagram.py +813 -0
  43. openretailscience/plots/venn.py +127 -0
  44. openretailscience/plots/waterfall.py +217 -0
  45. openretailscience/plugin.py +238 -0
  46. openretailscience/segmentation/__init__.py +0 -0
  47. openretailscience/segmentation/hml.py +97 -0
  48. openretailscience/segmentation/nlr.py +218 -0
  49. openretailscience/segmentation/rfm.py +307 -0
  50. openretailscience/segmentation/segstats.py +1177 -0
  51. openretailscience/segmentation/threshold.py +165 -0
  52. openretailscience/utils/__init__.py +0 -0
  53. openretailscience/utils/date.py +226 -0
  54. openretailscience/utils/filter_and_label.py +52 -0
  55. openretailscience/utils/label.py +79 -0
  56. openretailscience/utils/validation.py +45 -0
  57. openretailscience-0.45.0.dist-info/METADATA +364 -0
  58. openretailscience-0.45.0.dist-info/RECORD +60 -0
  59. openretailscience-0.45.0.dist-info/WHEEL +4 -0
  60. openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
@@ -0,0 +1,834 @@
1
+ """Helper functions for styling graphs."""
2
+
3
+ import importlib.resources as pkg_resources
4
+ import warnings
5
+ from collections.abc import Generator
6
+ from datetime import datetime
7
+ from itertools import cycle
8
+ from typing import Any, Literal
9
+
10
+ import matplotlib.ticker as mtick
11
+ import numpy as np
12
+ import pandas as pd
13
+ from matplotlib.axes import Axes
14
+ from matplotlib.axis import XAxis, YAxis
15
+ from matplotlib.dates import date2num
16
+ from matplotlib.text import Text
17
+ from scipy import stats
18
+
19
+ from openretailscience.options import PlotStyleHelper
20
+ from openretailscience.plots.styles.font_utils import get_font_properties
21
+ from openretailscience.plots.styles.styling_helpers import PlotStyler
22
+
23
+ ASSETS_PATH = pkg_resources.files("openretailscience").joinpath("assets")
24
+ _MAGNITUDE_SUFFIXES = ["", "K", "M", "B", "T", "P"]
25
+
26
+ _MIN_LINE_POINTS = 50
27
+ _MAX_LINE_POINTS = 500
28
+ _DATA_SIZE_MULTIPLIER = 3
29
+ _POSITIVE_X_FLOOR = 1e-6
30
+ _VARIANCE_THRESHOLD = 1e-10
31
+ _TEXT_X_PADDING = 0.05 # 5% from left
32
+
33
+ # Map regression_type -> (check_x_positive, check_y_positive)
34
+ _POSITIVITY_REQUIREMENTS: dict[str, tuple[bool, bool]] = {
35
+ "power": (True, True),
36
+ "logarithmic": (True, False),
37
+ "exponential": (False, True),
38
+ }
39
+
40
+
41
+ def _hatches_gen() -> Generator[str, None, None]:
42
+ """Returns a generator that cycles through predefined hatch patterns.
43
+
44
+ Yields:
45
+ str: The next hatch pattern in the sequence.
46
+ """
47
+ _hatches = ["/", "\\", "|", "-", "+", "x", "o", "O", ".", "*"]
48
+ return cycle(_hatches)
49
+
50
+
51
+ def human_format(
52
+ num: float,
53
+ pos: int | None = None, # noqa: ARG001 (pos is only used for Matplotlib compatibility)
54
+ decimals: int = 0,
55
+ prefix: str = "",
56
+ ) -> str:
57
+ """Format a number in a human-readable format for Matplotlib, discarding trailing zeros.
58
+
59
+ Args:
60
+ num (float): The number to format.
61
+ pos (int, optional): The position. Defaults to None. Only used for Matplotlib compatibility.
62
+ decimals (int, optional): The number of decimals. Defaults to 0.
63
+ prefix (str, optional): The prefix of the returned string, eg '$'. Defaults to "".
64
+
65
+ Returns:
66
+ str: The formatted number, with trailing zeros removed.
67
+ """
68
+ # The minimum difference between two numbers to receive a different suffix
69
+ minimum_magnitude_difference = 1000.0
70
+ magnitude = 0
71
+
72
+ # Keep dividing by 1000 until the number is small enough
73
+ while abs(num) >= minimum_magnitude_difference:
74
+ magnitude += 1
75
+ num /= minimum_magnitude_difference
76
+
77
+ # Check if the number rounds to exactly 1000 at the current magnitude
78
+ if round(abs(num), decimals) == minimum_magnitude_difference:
79
+ num /= minimum_magnitude_difference
80
+ magnitude += 1
81
+
82
+ # If magnitude exceeds the predefined suffixes, continue with multiples of "P"
83
+ if magnitude < len(_MAGNITUDE_SUFFIXES):
84
+ suffix = _MAGNITUDE_SUFFIXES[magnitude]
85
+ else:
86
+ # Calculate how many times beyond "P" we've gone and append that to "P"
87
+ extra_magnitude = magnitude - (len(_MAGNITUDE_SUFFIXES) - 1)
88
+ suffix = f"{1000**extra_magnitude}P"
89
+
90
+ # Format the number and remove trailing zeros
91
+ formatted_num = f"{prefix}%.{decimals}f" % num
92
+ formatted_num = formatted_num.rstrip("0").rstrip(".") if "." in formatted_num else formatted_num
93
+
94
+ return f"{formatted_num}{suffix}"
95
+
96
+
97
+ def truncate_to_x_digits(num_str: str, digits: int) -> str:
98
+ """Truncate a human-formatted number to the first `num_digits` significant digits.
99
+
100
+ Args:
101
+ num_str (str): The formatted number (e.g., '999.999K').
102
+ digits (int): The number of digits to keep.
103
+
104
+ Returns:
105
+ str: The truncated formatted number (e.g., '999.9K').
106
+ """
107
+ # Split the number part and the suffix (e.g., "999.999K" -> "999.999" and "K")
108
+ suffix = ""
109
+ for s in _MAGNITUDE_SUFFIXES:
110
+ if num_str.endswith(s) and s != "":
111
+ suffix = s
112
+ num_str = num_str[: -len(s)] # Remove the suffix for now
113
+ break
114
+
115
+ # Handle negative numbers
116
+ is_negative = num_str.startswith("-")
117
+ if is_negative:
118
+ num_str = num_str[1:] # Remove the negative sign for now
119
+
120
+ # Handle zero case explicitly
121
+ if float(num_str) == 0:
122
+ return f"0{suffix}"
123
+
124
+ # Handle small numbers explicitly to avoid scientific notation
125
+ scientific_notation_threshold = 1e-4
126
+ if abs(float(num_str)) < scientific_notation_threshold:
127
+ return f"{float(num_str):.{digits}f}".rstrip("0").rstrip(".")
128
+
129
+ digits_before_decimal = len(num_str.split(".")[0])
130
+ # Calculate how many digits to keep after the decimal
131
+ digits_to_keep_after_decimal = digits - digits_before_decimal
132
+
133
+ # Ensure truncation without rounding
134
+ if digits_to_keep_after_decimal > 0:
135
+ factor = 10**digits_to_keep_after_decimal
136
+ truncated_num = str(int(float(num_str) * factor) / factor)
137
+ else:
138
+ factor = 10**digits
139
+ truncated_num = str(int(float(num_str) * factor) / factor)
140
+
141
+ # Reapply the negative sign if needed
142
+ if is_negative:
143
+ truncated_num = f"-{truncated_num}"
144
+
145
+ # Remove unnecessary trailing zeros and decimal point
146
+ truncated_num = truncated_num.rstrip("0").rstrip(".")
147
+
148
+ return f"{truncated_num}{suffix}"
149
+
150
+
151
+ def standard_graph_styles(
152
+ ax: Axes,
153
+ title: str | None = None,
154
+ x_label: str | None = None,
155
+ y_label: str | None = None,
156
+ title_pad: int | None = None,
157
+ x_label_pad: int | None = None,
158
+ y_label_pad: int | None = None,
159
+ legend_title: str | None = None,
160
+ move_legend_outside: bool = False,
161
+ show_legend: bool = True,
162
+ ) -> Axes:
163
+ """Apply standard styles to a Matplotlib graph using styling helpers.
164
+
165
+ Args:
166
+ ax (Axes): The graph to apply the styles to.
167
+ title (str, optional): The title of the graph. Defaults to None.
168
+ x_label (str, optional): The x-axis label. Defaults to None.
169
+ y_label (str, optional): The y-axis label. Defaults to None.
170
+ title_pad (int, optional): The padding above the title. Defaults to styling context default.
171
+ x_label_pad (int, optional): The padding below the x-axis label. Defaults to styling context default.
172
+ y_label_pad (int, optional): The padding to the left of the y-axis label. Defaults to styling context default.
173
+ legend_title (str, optional): The title of the legend. If None, no legend title is applied. Defaults to None.
174
+ move_legend_outside (bool, optional): Whether to move the legend outside the plot. Defaults to False.
175
+ show_legend (bool): Whether to display the legend or not.
176
+
177
+ Returns:
178
+ Axes: The graph with the styles applied.
179
+ """
180
+ plot_styler = PlotStyler()
181
+
182
+ # Apply base plot styling
183
+ plot_styler.apply_base_styling(ax)
184
+
185
+ # Apply text styling
186
+ if title is not None:
187
+ plot_styler.apply_title(ax, title, title_pad)
188
+
189
+ if x_label is not None:
190
+ plot_styler.apply_label(ax, x_label, "x", x_label_pad)
191
+
192
+ if y_label is not None:
193
+ plot_styler.apply_label(ax, y_label, "y", y_label_pad)
194
+
195
+ # Apply tick styling
196
+ plot_styler.apply_ticks(ax)
197
+
198
+ # Apply legend styling if needed
199
+ if show_legend and (ax.get_legend() is not None or legend_title is not None or move_legend_outside):
200
+ plot_styler.apply_legend(ax, legend_title, move_legend_outside)
201
+
202
+ return ax
203
+
204
+
205
+ def standard_tick_styles(ax: Axes) -> Axes:
206
+ """Apply standard tick styles using styling helpers.
207
+
208
+ Args:
209
+ ax (Axes): The graph to apply the styles to.
210
+
211
+ Returns:
212
+ Axes: The graph with the styles applied.
213
+ """
214
+ plot_styler = PlotStyler()
215
+ plot_styler.apply_ticks(ax)
216
+ return ax
217
+
218
+
219
+ def apply_hatches(ax: Axes, num_segments: int) -> Axes:
220
+ """Apply hatch patterns to patches in a plot, such as bars, histograms, or area plots.
221
+
222
+ This function divides the patches in the given Axes object into the specified
223
+ number of segments and applies a different hatch pattern to each segment.
224
+
225
+ Args:
226
+ ax (Axes): The matplotlib Axes object containing the plot with patches (bars, histograms, etc.).
227
+ num_segments (int): The number of segments to divide the patches into, with each
228
+ segment receiving a different hatch pattern.
229
+
230
+ Returns:
231
+ Axes: The modified Axes object with hatches applied to the patches.
232
+ """
233
+ available_hatches = _hatches_gen()
234
+ patch_groups = np.array_split(ax.patches, num_segments)
235
+ for patch_group in patch_groups:
236
+ hatch = next(available_hatches)
237
+ for patch in patch_group:
238
+ patch.set_hatch(hatch)
239
+
240
+ legend = ax.get_legend()
241
+ if legend:
242
+ existing_hatches = [patch.get_hatch() for patch in ax.patches if patch.get_hatch() is not None]
243
+ unique_hatches = [hatch for idx, hatch in enumerate(existing_hatches) if hatch not in existing_hatches[:idx]]
244
+ for legend_patch, hatch in zip(legend.get_patches(), cycle(unique_hatches)):
245
+ legend_patch.set_hatch(hatch)
246
+
247
+ return ax
248
+
249
+
250
+ def not_none(value1: Any, value2: Any) -> Any: # noqa: ANN401
251
+ """Helper function that returns the first value that is not None.
252
+
253
+ Args:
254
+ value1: The first value.
255
+ value2: The second value.
256
+
257
+ Returns:
258
+ The first value that is not None.
259
+ """
260
+ if value1 is None:
261
+ return value2
262
+ return value1
263
+
264
+
265
+ def get_decimals(ylim: tuple[float, float], tick_values: list[float], max_decimals: int = 10) -> int:
266
+ """Helper function for the `human_format` function that determines the number of decimals to use for the y-axis.
267
+
268
+ Args:
269
+ ylim (tuple[float, float]): The y-axis limits.
270
+ tick_values (list[float]): The y-axis tick values.
271
+ max_decimals (int, optional): The maximum number of decimals to use. Defaults to 10.
272
+
273
+ Returns:
274
+ int: The number of decimals to use.
275
+ """
276
+ decimals = 0
277
+ while decimals < max_decimals:
278
+ tick_labels = [human_format(t, 0, decimals=decimals) for t in tick_values if t >= ylim[0] and t <= ylim[1]]
279
+ # Ensure no duplicate labels
280
+ if len(tick_labels) == len(set(tick_labels)):
281
+ break
282
+ decimals += 1
283
+ return decimals
284
+
285
+
286
+ def add_source_text(
287
+ ax: Axes,
288
+ source_text: str,
289
+ font_size: float | None = None,
290
+ vertical_padding: float = 2,
291
+ is_venn_diagram: bool = False,
292
+ ) -> Text:
293
+ """Add source text to the bottom left corner of a graph using styling helpers.
294
+
295
+ Args:
296
+ ax (Axes): The graph to add the source text to.
297
+ source_text (str): The source text.
298
+ font_size (float, optional): The font size of the source text. If None, uses styling context default.
299
+ vertical_padding (float, optional): The padding in ems below the x-axis label. Defaults to 2.
300
+ is_venn_diagram (bool, optional): Flag to indicate if the diagram is a Venn diagram.
301
+ If True, `x_norm` and `y_norm` will be set to fixed values. Defaults to False.
302
+
303
+ Returns:
304
+ Text: The source text.
305
+ """
306
+ plot_styler = PlotStyler()
307
+
308
+ return plot_styler.apply_source_text(
309
+ ax=ax,
310
+ text=source_text,
311
+ font_size=font_size,
312
+ vertical_padding=vertical_padding,
313
+ is_venn_diagram=is_venn_diagram,
314
+ )
315
+
316
+
317
+ def set_axis_percent(
318
+ fmt_axis: YAxis | XAxis,
319
+ xmax: float = 1,
320
+ decimals: int | None = None,
321
+ symbol: str | None = "%",
322
+ ) -> None:
323
+ """Format an axis to display values as percentages.
324
+
325
+ This function configures a matplotlib axis to display its tick labels as percentages
326
+ using matplotlib's PercentFormatter.
327
+
328
+ Args:
329
+ fmt_axis (YAxis | XAxis): The axis to format (either ax.yaxis or ax.xaxis).
330
+ xmax (float, optional): The value that represents 100%. Defaults to 1.
331
+ decimals (int | None, optional): Number of decimal places to include. If None,
332
+ automatically selects based on data range. Defaults to None.
333
+ symbol (str | None, optional): The symbol to use for percentage. If None,
334
+ no symbol is displayed. Defaults to "%".
335
+
336
+ Returns:
337
+ None: The function modifies the axis formatter in place.
338
+
339
+ Example:
340
+ ```python
341
+ import matplotlib.pyplot as plt
342
+ fig, ax = plt.subplots()
343
+ ax.plot([0, 0.25, 0.5, 0.75, 1.0], [0, 0.3, 0.5, 0.7, 1.0])
344
+ # Format y-axis as percentage
345
+ set_axis_percent(ax.yaxis)
346
+ # Format x-axis as percentage with 1 decimal place
347
+ set_axis_percent(ax.xaxis, decimals=1)
348
+ ```
349
+ """
350
+ return fmt_axis.set_major_formatter(mtick.PercentFormatter(xmax=xmax, decimals=decimals, symbol=symbol))
351
+
352
+
353
+ def _calculate_r_squared_original_space(y_actual: np.ndarray, y_predicted: np.ndarray) -> float:
354
+ """Calculate R² in original data space.
355
+
356
+ Args:
357
+ y_actual (np.ndarray): Actual y values.
358
+ y_predicted (np.ndarray): Predicted y values from regression model.
359
+
360
+ Returns:
361
+ float: R² value calculated in original data space.
362
+ """
363
+ ss_res = np.sum((y_actual - y_predicted) ** 2) # Sum of squares of residuals
364
+ ss_tot = np.sum((y_actual - np.mean(y_actual)) ** 2) # Total sum of squares
365
+
366
+ # Handle edge case where all y values are (nearly) identical
367
+ if ss_tot < _VARIANCE_THRESHOLD:
368
+ return 1.0 if ss_res < _VARIANCE_THRESHOLD else 0.0
369
+
370
+ return 1 - (ss_res / ss_tot)
371
+
372
+
373
+ def _perform_regression_calculation(
374
+ regression_type: str,
375
+ x_filtered: np.ndarray,
376
+ y_filtered: np.ndarray,
377
+ ) -> tuple[float, float, float]:
378
+ """Perform regression calculation and return coefficients and R² in original data space.
379
+
380
+ Args:
381
+ regression_type (str): Type of regression to perform.
382
+ x_filtered (np.ndarray): Filtered x data.
383
+ y_filtered (np.ndarray): Filtered y data.
384
+
385
+ Returns:
386
+ tuple[float, float, float]: param1, param2, r_squared (calculated in original data space)
387
+ """
388
+ if regression_type == "linear":
389
+ # Linear regression: y = mx + b
390
+ slope, intercept, r_value, _, _ = stats.linregress(x_filtered, y_filtered)
391
+ return slope, intercept, r_value**2
392
+
393
+ if regression_type == "power":
394
+ # Power law regression: y = ax^b → log(y) = log(a) + b*log(x)
395
+ log_x = np.log(x_filtered)
396
+ log_y = np.log(y_filtered)
397
+ slope, intercept, _, _, _ = stats.linregress(log_x, log_y)
398
+ a = np.exp(intercept) # Convert back: a = exp(intercept), b = slope
399
+ b = slope
400
+
401
+ # Calculate R² in original data space
402
+ y_predicted = a * (x_filtered**b)
403
+ r_squared = _calculate_r_squared_original_space(y_filtered, y_predicted)
404
+ return a, b, r_squared
405
+
406
+ if regression_type == "logarithmic":
407
+ # Logarithmic regression: y = a*ln(x) + b
408
+ log_x = np.log(x_filtered)
409
+ slope, intercept, _, _, _ = stats.linregress(log_x, y_filtered)
410
+ a = slope # a = slope, b = intercept
411
+ b = intercept
412
+
413
+ # Calculate R² in original data space
414
+ y_predicted = a * np.log(x_filtered) + b
415
+ r_squared = _calculate_r_squared_original_space(y_filtered, y_predicted)
416
+ return a, b, r_squared
417
+
418
+ if regression_type == "exponential":
419
+ # Exponential regression: y = ae^(bx) → ln(y) = ln(a) + bx
420
+ log_y = np.log(y_filtered)
421
+ slope, intercept, _, _, _ = stats.linregress(x_filtered, log_y)
422
+ a = np.exp(intercept) # Convert back: a = exp(intercept), b = slope
423
+ b = slope
424
+
425
+ # Calculate R² in original data space
426
+ y_predicted = a * np.exp(b * x_filtered)
427
+ r_squared = _calculate_r_squared_original_space(y_filtered, y_predicted)
428
+ return a, b, r_squared
429
+
430
+ msg = f"Unsupported regression type: {regression_type}"
431
+ raise ValueError(msg)
432
+
433
+
434
+ def _generate_regression_line(
435
+ regression_type: str,
436
+ param1: float,
437
+ param2: float,
438
+ x_min: float,
439
+ x_max: float,
440
+ data_size: int = 50,
441
+ ) -> tuple[np.ndarray, np.ndarray]:
442
+ """Generate regression line points for plotting with adaptive point calculation.
443
+
444
+ Args:
445
+ regression_type (str): Type of regression.
446
+ param1 (float): First parameter (slope/a coefficient).
447
+ param2 (float): Second parameter (intercept/b coefficient).
448
+ x_min (float): Minimum x value for line.
449
+ x_max (float): Maximum x value for line.
450
+ data_size (int): Number of original data points for adaptive calculation.
451
+
452
+ Returns:
453
+ tuple[np.ndarray, np.ndarray]: x_line, y_line arrays for plotting.
454
+ """
455
+ if regression_type == "linear":
456
+ # Linear: use endpoints for efficiency
457
+ x_line = np.array([x_min, x_max])
458
+ y_line = param1 * x_line + param2
459
+ return x_line, y_line
460
+
461
+ # For non-linear types, use adaptive point calculation
462
+ # Base points on data size but ensure smooth curves for complex functions
463
+ adaptive_points = max(data_size * _DATA_SIZE_MULTIPLIER, _MIN_LINE_POINTS)
464
+ num_points = min(adaptive_points, _MAX_LINE_POINTS)
465
+
466
+ # Derive from the single source of truth
467
+ requirements = _POSITIVITY_REQUIREMENTS.get(regression_type)
468
+ requires_positive_x = requirements is not None and requirements[0]
469
+ x_start = max(x_min, _POSITIVE_X_FLOOR) if requires_positive_x else x_min
470
+ x_line = np.linspace(x_start, x_max, num_points)
471
+
472
+ if regression_type == "power":
473
+ # Suppress overflow warning — large exponents can overflow to inf, which is filtered below
474
+ with np.errstate(over="ignore"):
475
+ y_line = param1 * (x_line**param2)
476
+ elif regression_type == "logarithmic":
477
+ y_line = param1 * np.log(x_line) + param2
478
+ elif regression_type == "exponential":
479
+ # Suppress overflow warning — large x values can overflow exp() to inf, which is filtered below
480
+ with np.errstate(over="ignore"):
481
+ y_line = param1 * np.exp(param2 * x_line)
482
+ else:
483
+ msg = f"Unsupported regression type for line generation: {regression_type}"
484
+ raise ValueError(msg)
485
+
486
+ # Filter out infinite/NaN values for types susceptible to overflow
487
+ if regression_type in ("power", "exponential"):
488
+ finite_mask = np.isfinite(y_line)
489
+ if not np.all(finite_mask):
490
+ x_line = x_line[finite_mask]
491
+ y_line = y_line[finite_mask]
492
+
493
+ return x_line, y_line
494
+
495
+
496
+ def _add_equation_text(
497
+ ax: Axes,
498
+ param1: float,
499
+ param2: float,
500
+ r_squared: float,
501
+ color: str,
502
+ text_position: float,
503
+ show_equation: bool,
504
+ show_r2: bool,
505
+ regression_type: str = "linear",
506
+ ) -> None:
507
+ """Add equation and R² text to the plot.
508
+
509
+ Args:
510
+ ax (Axes): The matplotlib axes object.
511
+ param1 (float): First regression parameter (slope/a coefficient).
512
+ param2 (float): Second regression parameter (intercept/b coefficient).
513
+ r_squared (float): The R² value of the regression.
514
+ color (str): The color of the text.
515
+ text_position (float): The relative y-position of the text.
516
+ show_equation (bool): Whether to display the equation.
517
+ show_r2 (bool): Whether to display the R² value.
518
+ regression_type (str): The type of regression for equation formatting.
519
+ """
520
+ if not (show_equation or show_r2):
521
+ return
522
+
523
+ style = PlotStyleHelper()
524
+
525
+ equation_parts = []
526
+
527
+ if show_equation:
528
+ if regression_type == "linear":
529
+ sign = "+" if param2 >= 0 else "-"
530
+ equation = f"y = {param1:.4g}x {sign} {abs(param2):.4g}"
531
+ elif regression_type == "power":
532
+ exp_str = f"({param2:.4g})" if param2 < 0 else f"{param2:.4g}"
533
+ equation = f"y = {param1:.4g}x^{exp_str}"
534
+ elif regression_type == "logarithmic":
535
+ sign = "+" if param2 >= 0 else "-"
536
+ equation = f"y = {param1:.4g}ln(x) {sign} {abs(param2):.4g}"
537
+ elif regression_type == "exponential":
538
+ equation = f"y = {param1:.4g}e^({param2:.4g}x)"
539
+ else:
540
+ msg = f"Unsupported regression type for equation formatting: {regression_type}"
541
+ raise ValueError(msg)
542
+
543
+ equation_parts.append(equation)
544
+
545
+ if show_r2:
546
+ r2_text = f"R² = {r_squared:.4g}"
547
+ equation_parts.append(r2_text)
548
+
549
+ text = "\n".join(equation_parts)
550
+
551
+ # Calculate text position (relative to axis bounds)
552
+ x_min, x_max = ax.get_xlim()
553
+ y_min, y_max = ax.get_ylim()
554
+ text_x = x_min + _TEXT_X_PADDING * (x_max - x_min)
555
+ text_y = y_min + text_position * (y_max - y_min)
556
+
557
+ ax.text(
558
+ text_x,
559
+ text_y,
560
+ text,
561
+ color=color,
562
+ fontsize=style.label_size,
563
+ fontproperties=get_font_properties(style.source_font),
564
+ bbox={"facecolor": "white", "alpha": 0.7, "edgecolor": "none"},
565
+ )
566
+
567
+
568
+ def _extract_plot_data(ax: Axes) -> tuple[np.ndarray, np.ndarray]:
569
+ """Extract x and y data from a matplotlib plot (line, scatter, or bar).
570
+
571
+ Supports multiple plot types:
572
+ - Line plots: Extracts data from line objects
573
+ - Bar charts: Extracts center positions and heights/widths, with automatic orientation detection
574
+ - Scatter plots: Extracts data from collection offsets
575
+
576
+ Args:
577
+ ax (Axes): The matplotlib axes object containing the plot.
578
+
579
+ Returns:
580
+ tuple[np.ndarray, np.ndarray]: The x and y data arrays.
581
+
582
+ Raises:
583
+ ValueError: If no plot data can be extracted.
584
+ """
585
+ # Try to get data from lines first (line plots)
586
+ lines = [line for line in ax.get_lines() if line.get_visible()]
587
+
588
+ if len(lines) > 0:
589
+ x_data = lines[0].get_xdata()
590
+ y_data = lines[0].get_ydata()
591
+ # Check for bar charts (patches)
592
+ elif hasattr(ax, "patches") and len(ax.patches) > 0:
593
+ # Detect bar orientation using BarContainer (stable API)
594
+ is_vertical = True # Default assumption
595
+ for container in ax.containers:
596
+ if hasattr(container, "orientation") and container.orientation:
597
+ is_vertical = container.orientation == "vertical"
598
+ break
599
+
600
+ if is_vertical:
601
+ # Vertical bars: x is center position, y is height
602
+ bar_data = [(patch.get_x() + patch.get_width() / 2, patch.get_height()) for patch in ax.patches]
603
+ else:
604
+ # Horizontal bars: x is width, y is center position
605
+ bar_data = [(patch.get_width(), patch.get_y() + patch.get_height() / 2) for patch in ax.patches]
606
+
607
+ # Sort by x-coordinate to ensure consistency with grouped/stacked bar charts
608
+ bar_data.sort(key=lambda point: point[0])
609
+ x_data, y_data = np.array(bar_data).T
610
+ # If no lines or bars, check for scatter plots (or other collections)
611
+ elif hasattr(ax, "collections") and len(ax.collections) > 0:
612
+ # Extract data from the first collection (e.g., scatter plot)
613
+ collection = ax.collections[0]
614
+ # Get the offsets which contain the x,y coordinates
615
+ if hasattr(collection, "get_offsets") and callable(collection.get_offsets):
616
+ offset_data = collection.get_offsets()
617
+ if len(offset_data) > 0:
618
+ x_data = offset_data[:, 0]
619
+ y_data = offset_data[:, 1]
620
+ else:
621
+ raise ValueError("No data points found in the collection")
622
+ else:
623
+ raise ValueError("Cannot extract data from this type of collection")
624
+ else:
625
+ raise ValueError("No visible lines or collections found in the plot")
626
+
627
+ return x_data, y_data
628
+
629
+
630
+ def _prepare_numeric_data(x_data: np.ndarray, y_data: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
631
+ """Convert plot data to numeric arrays suitable for regression analysis.
632
+
633
+ Args:
634
+ x_data (np.ndarray): The raw x-axis data from the plot.
635
+ y_data (np.ndarray): The raw y-axis data from the plot.
636
+
637
+ Returns:
638
+ tuple[np.ndarray, np.ndarray]: The numeric x and y data.
639
+
640
+ Raises:
641
+ ValueError: If data cannot be converted to numeric format or has insufficient valid points.
642
+ """
643
+ # Simple fallback indices in case we can't process the data
644
+ x_indices = np.arange(len(x_data))
645
+
646
+ # Check if x_data contains datetime objects
647
+ is_datetime = False
648
+ try:
649
+ # Try to find a non-null value to check its type
650
+ for val in x_data:
651
+ if val is not None:
652
+ is_datetime = isinstance(val, datetime | pd.Timestamp)
653
+ break
654
+ except (TypeError, IndexError):
655
+ pass
656
+
657
+ try:
658
+ # Handle datetime or numeric data appropriately
659
+ x_numeric = date2num(x_data) if is_datetime else np.array(x_data, dtype=float)
660
+ except (TypeError, ValueError):
661
+ # Fallback to simple indices if conversion fails
662
+ x_numeric = x_indices
663
+
664
+ try:
665
+ y_numeric = np.array(y_data, dtype=float)
666
+ except (TypeError, ValueError) as err:
667
+ raise ValueError("Cannot convert y-axis values to numeric format for regression") from err
668
+
669
+ # Create mask to filter out NaN values
670
+ valid_mask = ~np.isnan(x_numeric) & ~np.isnan(y_numeric)
671
+ if not np.any(valid_mask):
672
+ raise ValueError("No valid (non-NaN) data points for regression")
673
+
674
+ # Check that we have enough valid data points for regression
675
+ min_points_for_regression = 2
676
+ if np.sum(valid_mask) < min_points_for_regression:
677
+ error_msg = f"At least {min_points_for_regression} valid data points are required for regression analysis"
678
+ raise ValueError(error_msg)
679
+
680
+ x_filtered = x_numeric[valid_mask]
681
+ y_filtered = y_numeric[valid_mask]
682
+
683
+ # Check for zero variance in x — invalid for all regression types
684
+ if np.var(x_filtered) < _VARIANCE_THRESHOLD:
685
+ raise ValueError("Cannot perform regression: all x values are identical (zero variance)")
686
+
687
+ return x_filtered, y_filtered
688
+
689
+
690
+ def _validate_regression_data(
691
+ x_data: np.ndarray,
692
+ y_data: np.ndarray,
693
+ regression_type: str,
694
+ ) -> None:
695
+ """Validate data for specific regression types.
696
+
697
+ Args:
698
+ x_data (np.ndarray): The x-axis data.
699
+ y_data (np.ndarray): The y-axis data.
700
+ regression_type (str): The regression type being used.
701
+
702
+ Raises:
703
+ ValueError: If data contains values incompatible with the regression type.
704
+ """
705
+ requirements = _POSITIVITY_REQUIREMENTS.get(regression_type)
706
+ if requirements is None:
707
+ # Linear and other types use all data without positivity checks
708
+ return
709
+
710
+ check_x, check_y = requirements
711
+ axes_desc = " and ".join(name for name, required in [("x", check_x), ("y", check_y)] if required)
712
+ error_parts = []
713
+
714
+ if check_x:
715
+ count = int(np.sum(x_data <= 0))
716
+ if count > 0:
717
+ error_parts.append(f"{count} non-positive x value(s)")
718
+
719
+ if check_y:
720
+ count = int(np.sum(y_data <= 0))
721
+ if count > 0:
722
+ error_parts.append(f"{count} non-positive y value(s)")
723
+
724
+ if len(error_parts) > 0:
725
+ error_msg = (
726
+ f"{regression_type.capitalize()} regression requires all {axes_desc} values to be positive. "
727
+ f"Found {' and '.join(error_parts)}. "
728
+ f"Please remove or transform these values before applying {regression_type} regression."
729
+ )
730
+ raise ValueError(error_msg)
731
+
732
+ # Zero variance in y produces identical log(y) values, making regression meaningless
733
+ if check_y and np.var(y_data) < _VARIANCE_THRESHOLD:
734
+ raise ValueError("Cannot perform regression: all y values are identical (zero variance)")
735
+
736
+
737
+ def add_regression_line(
738
+ ax: Axes,
739
+ regression_type: Literal["linear", "power", "logarithmic", "exponential"] = "linear",
740
+ color: str = "red",
741
+ linestyle: str = "--",
742
+ text_position: float = 0.6,
743
+ show_equation: bool = True,
744
+ show_r2: bool = True,
745
+ **kwargs: Any, # noqa: ANN401 - forwarded to matplotlib's ax.plot()
746
+ ) -> Axes:
747
+ """Add a regression line with configurable algorithm to a matplotlib plot.
748
+
749
+ This function examines the data in a matplotlib Axes object and adds a
750
+ regression line to it. It supports line plots, scatter plots, and bar charts
751
+ (both vertical and horizontal), and can handle both numeric and datetime x-axis values.
752
+
753
+ For bar charts, the function automatically detects orientation using matplotlib's
754
+ BarContainer API and extracts appropriate x,y coordinates from bar positions and heights.
755
+
756
+ Note: If an axes contains multiple plot types (e.g., both lines and bars), the function
757
+ processes them in priority order: lines first, then bars, then scatter plots. Only the
758
+ first available plot type will be used for regression analysis.
759
+
760
+ Args:
761
+ ax (Axes): The matplotlib axes object containing the plot (line, scatter, or bar).
762
+ regression_type (Literal["linear", "power", "logarithmic", "exponential"], optional):
763
+ Regression algorithm to use. Supported values:
764
+ - "linear": y = mx + b (default, OLS regression)
765
+ - "power": y = ax^b (elasticity analysis, log-log transformation)
766
+ - "logarithmic": y = a*ln(x) + b (diminishing returns analysis)
767
+ - "exponential": y = ae^(bx) (growth/decay patterns)
768
+ Defaults to "linear".
769
+ color (str, optional): Color of the regression line. Defaults to "red".
770
+ linestyle (str, optional): Style of the regression line. Defaults to "--".
771
+ text_position (float, optional): Relative position (0-1) for the equation text. Defaults to 0.6.
772
+ show_equation (bool, optional): Whether to display the equation on the plot. Defaults to True.
773
+ show_r2 (bool, optional): Whether to display the R² value on the plot. Defaults to True.
774
+ kwargs: Additional keyword arguments to pass to the plot function.
775
+
776
+ Returns:
777
+ Axes: The matplotlib axes with the regression line added.
778
+
779
+ Raises:
780
+ ValueError: If the plot contains no visible lines, scatter points, or bar patches, or if
781
+ regression_type is not supported.
782
+
783
+ Examples:
784
+ Basic linear regression (backward compatible):
785
+ >>> ax = data.plot.scatter(x='price', y='demand')
786
+ >>> gu.add_regression_line(ax)
787
+
788
+ Power law regression for price elasticity:
789
+ >>> gu.add_regression_line(ax, regression_type="power")
790
+
791
+ Bar chart with regression line:
792
+ >>> ax = df.plot.bar(x='category', y='sales')
793
+ >>> gu.add_regression_line(ax, regression_type="linear")
794
+ """
795
+ # Validate regression type
796
+ supported_types = ["linear", "power", "logarithmic", "exponential"]
797
+ if regression_type not in supported_types:
798
+ error_msg = f"Unsupported regression_type '{regression_type}'. Supported types: {supported_types}"
799
+ raise ValueError(error_msg)
800
+
801
+ # Extract data from the plot
802
+ x_data, y_data = _extract_plot_data(ax)
803
+
804
+ # Convert to numeric data and validate
805
+ x_numeric, y_numeric = _prepare_numeric_data(x_data, y_data)
806
+
807
+ # Apply algorithm-specific data validation
808
+ _validate_regression_data(x_numeric, y_numeric, regression_type)
809
+
810
+ # Perform regression calculation
811
+ param1, param2, r_squared = _perform_regression_calculation(regression_type, x_numeric, y_numeric)
812
+
813
+ # Generate regression line points
814
+ x_min, x_max = ax.get_xlim()
815
+ data_size = len(x_numeric)
816
+ x_line, y_line = _generate_regression_line(regression_type, param1, param2, x_min, x_max, data_size)
817
+
818
+ if len(x_line) == 0:
819
+ warnings.warn(
820
+ f"Regression line for '{regression_type}' produced no finite values in the visible range. "
821
+ "Consider adjusting axis limits or using a different regression type.",
822
+ UserWarning,
823
+ stacklevel=2,
824
+ )
825
+ return ax
826
+
827
+ # Plot the regression line
828
+ ax.plot(x_line, y_line, color=color, linestyle=linestyle, **kwargs)
829
+
830
+ # Add equation and R² text if either is requested
831
+ if show_equation or show_r2:
832
+ _add_equation_text(ax, param1, param2, r_squared, color, text_position, show_equation, show_r2, regression_type)
833
+
834
+ return ax