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