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,275 @@
1
+ """Bubble chart visualizations for price distribution analysis across categories.
2
+
3
+ The bubble chart shows price distribution as vertical layers (price bands) with bubble sizes
4
+ representing the percentage of products in each price range for different categories like
5
+ retailers, countries, etc.
6
+
7
+ ### Core Features
8
+
9
+ - **Price Band Analysis**: Automatically bins price data into ranges using pandas.cut()
10
+ - **Categorical Grouping**: Groups data by categorical columns (retailers, countries, etc.)
11
+ - **Bubble Sizing**: Bubble sizes represent percentage of products in each price band per group
12
+ - **Flexible Binning**: Supports both integer (equal-width bins) and array (custom boundaries) inputs
13
+ - **Grid Layout**: X-axis shows categories, Y-axis shows price bands
14
+
15
+ ### Use Cases
16
+
17
+ - **Retailer Price Comparison**: Compare price distributions across different retailers
18
+ - **Regional Price Analysis**: Analyze price positioning by country/region
19
+ - **Competitive Pricing**: Identify pricing gaps and opportunities
20
+ - **Price Architecture Visualization**: Visualize competitive pricing landscapes
21
+
22
+ ### Limitations
23
+
24
+ - **Pandas DataFrame Only**: No Ibis table support
25
+ - **Pre-aggregated Data**: Data should be at product level (one row per product)
26
+ - **Numeric Price Column**: Requires numeric price/value column for binning
27
+ """
28
+
29
+ import matplotlib.pyplot as plt
30
+ import pandas as pd
31
+ from matplotlib.axes import Axes, SubplotBase
32
+ from matplotlib.lines import Line2D
33
+
34
+ import openretailscience.plots.styles.graph_utils as gu
35
+ from openretailscience.plots.styles.colors import get_plot_colors
36
+
37
+
38
+ def _validate_inputs(
39
+ df: pd.DataFrame,
40
+ value_col: str,
41
+ group_col: str,
42
+ bins: int | list[float],
43
+ ) -> tuple[pd.DataFrame, int | list[float]]:
44
+ """Validates and processes inputs for price distribution plotting.
45
+
46
+ Args:
47
+ df: Input DataFrame containing product-level data.
48
+ value_col: Column containing the price/value data.
49
+ group_col: Column containing the categorical grouping.
50
+ bins: Either number of equal-width bins (int) or custom bin boundaries (list).
51
+
52
+ Returns:
53
+ Tuple of (cleaned_dataframe, validated_bins).
54
+
55
+ Raises:
56
+ ValueError: If DataFrame is empty, columns don't exist, value column is not numeric,
57
+ or bins parameter is invalid.
58
+ KeyError: If specified columns are not found in DataFrame.
59
+ TypeError: If bins parameter has invalid type.
60
+ """
61
+ # Validate DataFrame is not empty
62
+ if df.empty:
63
+ raise ValueError("Cannot plot with empty DataFrame")
64
+
65
+ # Validate columns exist
66
+ if value_col not in df.columns:
67
+ msg = f"value_col '{value_col}' not found in DataFrame"
68
+ raise KeyError(msg)
69
+
70
+ if group_col not in df.columns:
71
+ msg = f"group_col '{group_col}' not found in DataFrame"
72
+ raise KeyError(msg)
73
+
74
+ # Validate value column is numeric
75
+ if not pd.api.types.is_numeric_dtype(df[value_col]):
76
+ msg = f"value_col '{value_col}' must be numeric for binning"
77
+ raise ValueError(msg)
78
+
79
+ # Validate bins parameter
80
+ validated_bins = _validate_bins_parameter(bins)
81
+
82
+ # Remove rows with missing values in key columns
83
+ df_clean = df[[value_col, group_col]].dropna()
84
+
85
+ if df_clean.empty:
86
+ msg = f"No valid data after removing missing values from {value_col} and {group_col}"
87
+ raise ValueError(msg)
88
+
89
+ return df_clean, validated_bins
90
+
91
+
92
+ def _validate_bins_parameter(bins: int | list[float]) -> int | list[float]:
93
+ """Validates and processes the bins parameter for price distribution plotting.
94
+
95
+ Args:
96
+ bins: Either number of equal-width bins (int) or custom bin boundaries (list).
97
+
98
+ Returns:
99
+ Validated and processed bins parameter.
100
+
101
+ Raises:
102
+ ValueError: If bins parameter is invalid.
103
+ TypeError: If bins parameter has invalid type.
104
+ """
105
+ if isinstance(bins, int):
106
+ if bins <= 0:
107
+ raise ValueError("bins must be a positive integer")
108
+ return bins
109
+ if isinstance(bins, list):
110
+ min_bins = 2
111
+ if len(bins) < min_bins:
112
+ raise ValueError("bins list must contain at least 2 values")
113
+ if not all(isinstance(x, int | float) for x in bins):
114
+ raise ValueError("All values in bins list must be numeric")
115
+ return sorted(bins)
116
+ msg = "bins must be either an integer or a list of numeric values"
117
+ raise TypeError(msg)
118
+
119
+
120
+ def plot(
121
+ df: pd.DataFrame,
122
+ value_col: str,
123
+ group_col: str,
124
+ bins: int | list[float],
125
+ title: str | None = None,
126
+ x_label: str | None = None,
127
+ y_label: str | None = None,
128
+ legend_title: str | None = None,
129
+ ax: Axes | None = None,
130
+ source_text: str | None = None,
131
+ move_legend_outside: bool = False,
132
+ **kwargs: dict[str, any],
133
+ ) -> SubplotBase:
134
+ """Creates a bubble chart visualization showing price distribution analysis across categories.
135
+
136
+ The chart displays price bands as vertical layers with bubble sizes representing the percentage
137
+ of products in each price range for different groups (retailers, countries, etc.).
138
+
139
+ Args:
140
+ df (pd.DataFrame): Input DataFrame containing product-level data.
141
+ value_col (str): Column containing the price/value data (e.g., "unit_price").
142
+ group_col (str): Column containing the categorical grouping (e.g., "retailer").
143
+ bins (int | list[float]): Either number of equal-width bins (int) or custom bin boundaries (list).
144
+ title (str, optional): The title of the plot. Defaults to None.
145
+ x_label (str, optional): The label for the x-axis. Defaults to None.
146
+ y_label (str, optional): The label for the y-axis. Defaults to None.
147
+ legend_title (str, optional): The title for the legend. Defaults to None.
148
+ ax (Axes, optional): The Matplotlib Axes object to plot on. Defaults to None.
149
+ source_text (str, optional): Text to be displayed as a source at the bottom of the plot. Defaults to None.
150
+ move_legend_outside (bool, optional): Whether to move the legend outside the plot area. Defaults to False.
151
+ **kwargs (dict[str, Any]): Additional keyword arguments for the scatter plot function.
152
+
153
+ Returns:
154
+ SubplotBase: The Matplotlib Axes object with the generated bubble chart.
155
+
156
+ Raises:
157
+ ValueError: If DataFrame is empty, columns don't exist, or bins parameter is invalid.
158
+ KeyError: If specified columns are not found in DataFrame.
159
+ TypeError: If bins parameter has invalid type.
160
+ """
161
+ # Validate inputs and get clean data
162
+ df_clean, bins = _validate_inputs(df, value_col, group_col, bins)
163
+
164
+ # Create price bins
165
+ df_clean["price_bin"] = pd.cut(df_clean[value_col], bins=bins, include_lowest=True)
166
+
167
+ # Calculate percentage distribution for each group
168
+ group_totals = df_clean.groupby(group_col, observed=True).size()
169
+ bin_counts = df_clean.groupby([group_col, "price_bin"], observed=True).size().unstack(fill_value=0)
170
+
171
+ # Convert to proportions (0-1 range)
172
+ proportions = bin_counts.div(group_totals, axis=0)
173
+
174
+ ax = ax or plt.gca()
175
+
176
+ # Get unique groups and bins
177
+ groups = proportions.index.tolist()
178
+ price_bins = proportions.columns.tolist()
179
+
180
+ # Set up color mapping
181
+ colors = get_plot_colors(len(groups))
182
+
183
+ alpha = kwargs.pop("alpha", 0.7)
184
+ s_scale = kwargs.pop("s", 800) # size for bubbles
185
+ edge_color = kwargs.pop("edgecolor", "black") # black stroke around bubbles
186
+ line_width = kwargs.pop("linewidth", 1.5) # Stroke width
187
+
188
+ # Validate that we have some data
189
+ if proportions.max().max() == 0 or pd.isna(proportions.max().max()):
190
+ raise ValueError("All proportions are zero - no data falls within the specified bins")
191
+
192
+ # Melt to get all (group, price_bin) combinations with their proportions
193
+ melted = proportions.reset_index().melt(id_vars=group_col, var_name="price_bin", value_name="proportion")
194
+ # Filter out zero proportions to avoid invisible bubbles
195
+ melted = melted[melted["proportion"] > 0]
196
+
197
+ if len(melted) > 0: # Only plot if there are non-zero proportions
198
+ x_positions = [groups.index(group) for group in melted[group_col]]
199
+ y_positions = [price_bins.index(price_bin) for price_bin in melted["price_bin"]]
200
+ # Calculate bubble sizes using absolute proportion values for cross-group comparison
201
+ bubble_sizes = (melted["proportion"] * s_scale).to_numpy()
202
+ bubble_colors = [colors[groups.index(group)] for group in melted[group_col]]
203
+
204
+ ax.scatter(
205
+ x_positions,
206
+ y_positions,
207
+ s=bubble_sizes,
208
+ c=bubble_colors,
209
+ alpha=alpha,
210
+ edgecolor=edge_color,
211
+ linewidth=line_width,
212
+ **kwargs,
213
+ )
214
+
215
+ ax.set_xticks(range(len(groups)))
216
+ ax.set_xticklabels(groups)
217
+ ax.set_yticks(range(len(price_bins)))
218
+
219
+ # Format price bin labels to be more user-friendly
220
+ formatted_labels = []
221
+ for bin_ in price_bins:
222
+ # Extract left and right bounds from pandas Interval
223
+ left = bin_.left
224
+ right = bin_.right
225
+ # Format as price ranges
226
+ formatted_labels.append(f"{left:.1f} - {right:.1f}")
227
+
228
+ ax.set_yticklabels(formatted_labels)
229
+
230
+ # Apply standard styling but completely disable legend handling
231
+ ax = gu.standard_graph_styles(
232
+ ax=ax,
233
+ title=title,
234
+ x_label=x_label,
235
+ y_label=y_label,
236
+ legend_title=None,
237
+ move_legend_outside=False,
238
+ show_legend=False,
239
+ )
240
+
241
+ # Create custom legend with uniform circle sizes AFTER standard_graph_styles
242
+ if len(groups) > 1: # Only create legend if there are multiple groups
243
+ legend_elements = []
244
+ for i, _group in enumerate(groups):
245
+ legend_elements.append(
246
+ Line2D(
247
+ [0],
248
+ [0],
249
+ marker="o",
250
+ color="w",
251
+ markerfacecolor=colors[i],
252
+ markersize=8,
253
+ markeredgecolor="white",
254
+ markeredgewidth=1,
255
+ alpha=0.8,
256
+ linestyle="None",
257
+ ),
258
+ )
259
+
260
+ if move_legend_outside:
261
+ ax.legend(
262
+ legend_elements,
263
+ groups,
264
+ title=legend_title,
265
+ bbox_to_anchor=(1.05, 1),
266
+ loc="upper left",
267
+ frameon=False,
268
+ )
269
+ else:
270
+ ax.legend(legend_elements, groups, title=legend_title, frameon=False)
271
+
272
+ if source_text:
273
+ gu.add_source_text(ax=ax, source_text=source_text)
274
+
275
+ return gu.standard_tick_styles(ax=ax)
@@ -0,0 +1,382 @@
1
+ """This module provides functionality for creating scatter plots and bubble charts from pandas DataFrames.
2
+
3
+ It is designed to visualize relationships between variables, highlight distributions,
4
+ and compare different categories using scatter points with optional variable sizing
5
+ for bubble chart functionality.
6
+
7
+ ### Core Features
8
+
9
+ - **Flexible X-Axis Handling**: Uses an index or a specified x-axis column (**`x_col`**) for plotting.
10
+ - **Multiple Scatter Groups**: Supports plotting multiple columns (**`value_col`**) or groups (**`group_col`**).
11
+ - **Bubble Chart Support**: Variable point sizes via **`size_col`** and **`size_scale`** parameters.
12
+ - **Point Labels**: Text labels with automatic positioning to avoid overlaps.
13
+ - **Dynamic Color Mapping**: Automatically selects a colormap based on the number of groups.
14
+
15
+ ### Use Cases
16
+
17
+ - **Category-Based Scatter Plots**: Compare different categories using scatter points.
18
+ - **Bubble Charts**: Visualize three dimensions of data with x, y positions and point sizes.
19
+ - **Labeled Scatter Plots**: Identify specific data points with text labels (e.g., product names, store IDs).
20
+
21
+ ### Limitations and Warnings
22
+
23
+ - **Pre-Aggregated Data Required**: Data should be pre-aggregated before being passed to the function.
24
+ - **Label Limitations**: Point labels are not supported when `value_col` is a list (raises ValueError).
25
+ - **Size Column Requirements**: `size_col` must contain numeric, non-negative values.
26
+ """
27
+
28
+ import warnings
29
+ from typing import Any
30
+
31
+ import matplotlib.pyplot as plt
32
+ import pandas as pd
33
+ import textalloc as ta
34
+ from matplotlib.axes import Axes, SubplotBase
35
+
36
+ import openretailscience.plots.styles.graph_utils as gu
37
+ from openretailscience.options import PlotStyleHelper
38
+ from openretailscience.plots.styles.colors import get_plot_colors
39
+
40
+
41
+ def _handle_size_params(
42
+ df: pd.DataFrame,
43
+ size_col: str | None,
44
+ size_scale: float,
45
+ kwargs: dict[str, Any],
46
+ ) -> dict[str, Any]:
47
+ """Validate size parameters and resolve conflicting kwargs.
48
+
49
+ Args:
50
+ df (pd.DataFrame): DataFrame containing the data.
51
+ size_col (str | None): Column name containing values to determine point sizes.
52
+ size_scale (float): Scaling factor for point sizes.
53
+ kwargs (dict[str, Any]): Keyword arguments for matplotlib scatter function.
54
+
55
+ Returns:
56
+ dict[str, Any]: The original kwargs, or a copy with the 's' key removed if it was present.
57
+
58
+ Raises:
59
+ KeyError: If size_col doesn't exist in DataFrame.
60
+ ValueError: If size_col contains non-numeric values.
61
+ ValueError: If size_col contains negative values.
62
+ ValueError: If size_scale is not positive when size_col is specified.
63
+ """
64
+ if size_col is None:
65
+ return kwargs
66
+
67
+ if size_col not in df.columns:
68
+ msg = f"size_col '{size_col}' not found in DataFrame"
69
+ raise KeyError(msg)
70
+
71
+ if not pd.api.types.is_numeric_dtype(df[size_col]):
72
+ msg = f"size_col '{size_col}' must contain numeric values"
73
+ raise ValueError(msg)
74
+
75
+ if (df[size_col] < 0).any():
76
+ msg = f"size_col '{size_col}' contains negative values, which are not supported for bubble sizes"
77
+ raise ValueError(msg)
78
+
79
+ if size_scale <= 0:
80
+ msg = "size_scale must be a positive number"
81
+ raise ValueError(msg)
82
+
83
+ if "s" in kwargs:
84
+ warnings.warn(
85
+ "The 's' keyword argument is ignored when 'size_col' is specified. "
86
+ "Point sizes are controlled by 'size_col'.",
87
+ UserWarning,
88
+ stacklevel=3,
89
+ )
90
+ kwargs = {k: v for k, v in kwargs.items() if k != "s"}
91
+
92
+ return kwargs
93
+
94
+
95
+ def _process_size_data(
96
+ df: pd.DataFrame,
97
+ size_col: str | None,
98
+ size_scale: float,
99
+ x_col: str | None,
100
+ group_col: str | None,
101
+ ) -> pd.DataFrame | pd.Series | None:
102
+ """Process size data for bubble charts.
103
+
104
+ Args:
105
+ df (pd.DataFrame): DataFrame containing the data.
106
+ size_col (str | None): Column name containing values to determine point sizes.
107
+ size_scale (float): Scaling factor for point sizes.
108
+ x_col (str | None): Column name for x-values. If None, uses index.
109
+ group_col (str | None): Column name for grouping. If None, treats as single series.
110
+
111
+ Returns:
112
+ pd.DataFrame | pd.Series | None: Processed size data aligned with plot structure, or None if no size_col.
113
+ """
114
+ if size_col is None:
115
+ return None
116
+
117
+ if group_col is None:
118
+ # Extract size values before set_index, which removes x_col from columns
119
+ size_values = df[size_col] * size_scale
120
+ index = df[x_col] if x_col is not None else df.index
121
+ return pd.Series(size_values.to_numpy(), index=index)
122
+
123
+ # For grouped data, create size array that aligns with pivot structure
124
+ size_pivot = (
125
+ df.pivot(columns=group_col, values=size_col)
126
+ if x_col is None
127
+ else df.pivot(index=x_col, columns=group_col, values=size_col)
128
+ )
129
+ return size_pivot * size_scale
130
+
131
+
132
+ def _create_scatter_plot(
133
+ ax: Axes,
134
+ pivot_df: pd.DataFrame,
135
+ colors: list[str],
136
+ size_data: pd.DataFrame | pd.Series | None,
137
+ group_col: str | None,
138
+ is_multi_scatter: bool,
139
+ alpha: float,
140
+ **kwargs: Any, # noqa: ANN401
141
+ ) -> None:
142
+ """Create scatter plots for each column in pivot_df.
143
+
144
+ Args:
145
+ ax (Axes): Matplotlib axes object to plot on.
146
+ pivot_df (pd.DataFrame): DataFrame with pivoted data for plotting.
147
+ colors (list[str]): List of colors for each column.
148
+ size_data (pd.DataFrame | pd.Series | None): Processed size data for bubble charts.
149
+ group_col (str | None): Column name for grouping.
150
+ is_multi_scatter (bool): Whether this is a multi-series scatter plot.
151
+ alpha (float): Alpha transparency value.
152
+ **kwargs: Additional keyword arguments for matplotlib scatter function.
153
+ """
154
+ for col, color_val in zip(pivot_df.columns, colors, strict=False):
155
+ # Get size values for this column if size_col is specified
156
+ sizes = None
157
+ if size_data is not None:
158
+ sizes = size_data if group_col is None else size_data[col]
159
+
160
+ # Filter out NaN values for grouped data
161
+ if group_col is not None:
162
+ # Get non-NaN mask for both y-values and sizes
163
+ y_values = pivot_df[col]
164
+ mask = y_values.notna()
165
+
166
+ x_values = pivot_df.index[mask]
167
+ y_values = y_values[mask]
168
+
169
+ if sizes is not None:
170
+ sizes = sizes[mask]
171
+ else:
172
+ x_values = pivot_df.index
173
+ y_values = pivot_df[col]
174
+
175
+ scatter_kwargs: dict[str, Any] = {
176
+ "color": color_val,
177
+ "label": col if is_multi_scatter else None,
178
+ "alpha": alpha,
179
+ **kwargs,
180
+ }
181
+ if sizes is not None:
182
+ scatter_kwargs["s"] = sizes
183
+
184
+ ax.scatter(x_values, y_values, **scatter_kwargs)
185
+
186
+
187
+ def _add_point_labels(
188
+ ax: Axes,
189
+ df: pd.DataFrame,
190
+ value_col: str,
191
+ label_col: str,
192
+ x_col: str | None = None,
193
+ group_col: str | None = None,
194
+ label_kwargs: dict[str, Any] | None = None,
195
+ ) -> None:
196
+ """Add text labels to scatter plot points with automatic positioning.
197
+
198
+ Args:
199
+ ax (Axes): Matplotlib axes object to add labels to.
200
+ df (pd.DataFrame): DataFrame containing the data.
201
+ value_col (str): Column name for y-values.
202
+ label_col (str): Column name containing text labels.
203
+ x_col (str | None): Column name for x-values. If None, uses index.
204
+ group_col (str | None): Column name for grouping. If None, treats as single series.
205
+ label_kwargs (dict[str, Any] | None): Additional arguments passed to textalloc.allocate().
206
+ """
207
+ # Get style configuration for label styling
208
+ style = PlotStyleHelper()
209
+
210
+ # Prepare data for labeling - drop rows with NaN in value_col, label_col, or x_col
211
+ cols_to_check = [value_col, label_col]
212
+ if x_col is not None:
213
+ cols_to_check.append(x_col)
214
+ data_with_labels_df = df.dropna(subset=cols_to_check)
215
+
216
+ # Get x-values (either from x_col or use index)
217
+ x_values = data_with_labels_df[x_col] if x_col is not None else data_with_labels_df.index
218
+
219
+ if group_col is None:
220
+ # Single series - vectorized approach
221
+ all_x_coords = x_values.tolist()
222
+ all_y_coords = data_with_labels_df[value_col].tolist()
223
+ all_labels = data_with_labels_df[label_col].astype(str).tolist()
224
+ else:
225
+ # Multi-series with groups - vectorized approach
226
+ all_x_coords = data_with_labels_df.index.to_numpy() if x_col is None else data_with_labels_df[x_col].to_numpy()
227
+ all_y_coords = data_with_labels_df[value_col].to_numpy()
228
+ all_labels = data_with_labels_df[label_col].astype(str).to_numpy()
229
+
230
+ # Apply textalloc to avoid overlaps
231
+ if len(all_x_coords) > 0 and len(all_y_coords) > 0 and len(all_labels) > 0:
232
+ # Set default textalloc parameters
233
+ allocate_kwargs = {
234
+ "textsize": style.data_label_size,
235
+ "x_scatter": all_x_coords,
236
+ "y_scatter": all_y_coords,
237
+ "nbr_candidates": 50, # More candidates for better positioning
238
+ "draw_lines": False, # Remove lines to scatter points
239
+ }
240
+
241
+ # Override with user-provided kwargs
242
+ if label_kwargs:
243
+ allocate_kwargs.update(label_kwargs)
244
+
245
+ ta.allocate(
246
+ ax,
247
+ all_x_coords,
248
+ all_y_coords,
249
+ all_labels,
250
+ **allocate_kwargs,
251
+ )
252
+
253
+
254
+ def plot(
255
+ df: pd.DataFrame | pd.Series,
256
+ value_col: str | list[str],
257
+ x_label: str | None = None,
258
+ y_label: str | None = None,
259
+ title: str | None = None,
260
+ x_col: str | None = None,
261
+ group_col: str | None = None,
262
+ size_col: str | None = None,
263
+ size_scale: float = 1.0,
264
+ ax: Axes | None = None,
265
+ source_text: str | None = None,
266
+ legend_title: str | None = None,
267
+ move_legend_outside: bool = False,
268
+ label_col: str | None = None,
269
+ label_kwargs: dict[str, Any] | None = None,
270
+ **kwargs: Any, # noqa: ANN401
271
+ ) -> SubplotBase:
272
+ """Plots a scatter chart for the given `value_col` over `x_col` or index, with optional grouping by `group_col`.
273
+
274
+ Args:
275
+ df (pd.DataFrame or pd.Series): The dataframe or series to plot.
276
+ value_col (str or list of str): The column(s) to plot.
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 scatter groups.
282
+ size_col (str, optional): The column name containing values to determine point sizes.
283
+ If None, all points have uniform size. Creates bubble charts when specified.
284
+ When used with multiple value_col columns, the same size values apply to all series.
285
+ size_scale (float, optional): Scaling factor for point sizes. Default: 1.0.
286
+ Actual size = size_col_value * size_scale.
287
+ ax (Axes, optional): Matplotlib axes object to plot on.
288
+ source_text (str, optional): The source text to add to the plot.
289
+ legend_title (str, optional): The title of the legend.
290
+ move_legend_outside (bool, optional): Move the legend outside the plot.
291
+ label_col (str, optional): Column name containing text labels for each point.
292
+ Not supported when value_col is a list. Defaults to None.
293
+ label_kwargs (dict, optional): Keyword arguments passed to textalloc.allocate().
294
+ Common options: textsize, nbr_candidates, min_distance, max_distance, draw_lines.
295
+ By default, draw_lines=False to avoid lines connecting labels to points.
296
+ Defaults to None.
297
+ **kwargs: Additional keyword arguments for matplotlib scatter function.
298
+
299
+ Returns:
300
+ SubplotBase: The matplotlib axes object.
301
+
302
+ Raises:
303
+ ValueError: If `value_col` is a list and `group_col` is provided (which causes ambiguity in plotting).
304
+ ValueError: If `label_col` is provided when `value_col` is a list.
305
+ KeyError: If `label_col` doesn't exist in DataFrame.
306
+ KeyError: If `size_col` doesn't exist in DataFrame.
307
+ ValueError: If `size_col` contains non-numeric or negative values.
308
+ ValueError: If `size_scale` is not positive when `size_col` is specified.
309
+ """
310
+ if isinstance(df, pd.Series):
311
+ df = df.to_frame()
312
+
313
+ if isinstance(value_col, list) and group_col:
314
+ raise ValueError("Cannot use both a list for `value_col` and a `group_col`. Choose one.")
315
+
316
+ if label_col is not None:
317
+ if isinstance(value_col, list):
318
+ raise ValueError(
319
+ "label_col is not supported when value_col is a list. "
320
+ "Please use a single value_col or create separate plots.",
321
+ )
322
+
323
+ if label_col not in df.columns:
324
+ msg = f"label_col '{label_col}' not found in DataFrame"
325
+ raise KeyError(msg)
326
+
327
+ # Validate size parameters and resolve conflicting kwargs
328
+ kwargs = _handle_size_params(df, size_col, size_scale, kwargs)
329
+
330
+ if group_col is None:
331
+ pivot_df = df.set_index(x_col if x_col is not None else df.index)[
332
+ [value_col] if isinstance(value_col, str) else value_col
333
+ ]
334
+ else:
335
+ pivot_df = (
336
+ df.pivot(columns=group_col, values=value_col)
337
+ if x_col is None
338
+ else df.pivot(index=x_col, columns=group_col, values=value_col)
339
+ )
340
+
341
+ is_multi_scatter = (group_col is not None) or (isinstance(value_col, list) and len(value_col) > 1)
342
+
343
+ num_colors = len(pivot_df.columns) if is_multi_scatter else 1
344
+ default_colors = get_plot_colors(num_colors)
345
+
346
+ # Handle color parameter - can be single color or list of colors
347
+ color = kwargs.pop("color", default_colors)
348
+ colors = [color] * num_colors if not isinstance(color, list) else color
349
+
350
+ # Process size data if size_col is specified
351
+ size_data = _process_size_data(df, size_col, size_scale, x_col, group_col)
352
+
353
+ ax = ax or plt.gca()
354
+ alpha = kwargs.pop("alpha", 0.7)
355
+
356
+ _create_scatter_plot(ax, pivot_df, colors, size_data, group_col, is_multi_scatter, alpha, **kwargs)
357
+
358
+ # Add labels if requested
359
+ if label_col is not None:
360
+ _add_point_labels(
361
+ ax=ax,
362
+ df=df,
363
+ value_col=value_col,
364
+ label_col=label_col,
365
+ x_col=x_col,
366
+ group_col=group_col,
367
+ label_kwargs=label_kwargs,
368
+ )
369
+
370
+ ax = gu.standard_graph_styles(
371
+ ax=ax,
372
+ title=title,
373
+ x_label=x_label,
374
+ y_label=y_label,
375
+ legend_title=legend_title,
376
+ move_legend_outside=move_legend_outside,
377
+ )
378
+
379
+ if source_text is not None:
380
+ gu.add_source_text(ax=ax, source_text=source_text)
381
+
382
+ return gu.standard_tick_styles(ax)
@@ -0,0 +1 @@
1
+ """Graph and style helpers for OpenRetailScience."""