Skip to content

Price Plot

Price-band bubble distribution across categories.

x = category, y = price bands, bubble area proportional to the share of each group's products in a band. One row per product with a numeric price column (pandas only).

plot(df, value_col, group_col, bins, title=None, eyebrow=None, subtitle=None, x_label=None, y_label=None, legend_title=None, figsize=None, ax=None, source_text=None, move_legend_outside=False, **kwargs)

Plot a price-band bubble chart: categories on x, price bands on y.

Bubble area is proportional to the absolute share of each group's products in a band, so sizes stay comparable across groups. Y tick labels show each band as "lower - upper" to one decimal.

Parameters:

Name Type Description Default
df DataFrame

Product-level frame (one row per product).

required
value_col str

Numeric price/value column to bin.

required
group_col str

Categorical grouping column (x-axis).

required
bins int or list[float]

Number of equal-width bins, or custom boundaries.

required
title str

Plot title.

None
eyebrow str

Uppercase label rendered above the title.

None
subtitle str

Supporting copy rendered below the title.

None
x_label str

X-axis label; omitted means no label.

None
y_label str

Y-axis label; omitted means no label.

None
legend_title str

Legend title.

None
figsize tuple[int, int]

Figure size, used only when ax is None.

None
ax Axes

Axes to plot on.

None
source_text str

Source attribution rendered at the bottom.

None
move_legend_outside bool

Move the legend outside the plot.

False
**kwargs Any

Forwarded to matplotlib scatter; s scales bubble area (default 2000) and edgecolor defaults to black.

{}

Raises:

Type Description
ValueError

If df is empty, value_col is not numeric, every row is missing value_col or group_col, or no data falls within the bins.

KeyError

If value_col or group_col is not a column of df.

TypeError

If bins is neither an int nor a list.

Source code in openretailscience/plots/price.py
def plot(
    df: pd.DataFrame,
    value_col: str,
    group_col: str,
    bins: int | list[float],
    title: str | None = None,
    eyebrow: str | None = None,
    subtitle: str | None = None,
    x_label: str | None = None,
    y_label: str | None = None,
    legend_title: str | None = None,
    figsize: tuple[int, int] | None = None,
    ax: Axes | None = None,
    source_text: str | None = None,
    move_legend_outside: bool = False,
    **kwargs: Any,  # noqa: ANN401
) -> SubplotBase:
    """Plot a price-band bubble chart: categories on x, price bands on y.

    Bubble area is proportional to the absolute share of each group's products in a band,
    so sizes stay comparable across groups. Y tick labels show each band as "lower - upper" to one
    decimal.

    Args:
        df (pd.DataFrame): Product-level frame (one row per product).
        value_col (str): Numeric price/value column to bin.
        group_col (str): Categorical grouping column (x-axis).
        bins (int or list[float]): Number of equal-width bins, or custom boundaries.
        title (str, optional): Plot title.
        eyebrow (str, optional): Uppercase label rendered above the title.
        subtitle (str, optional): Supporting copy rendered below the title.
        x_label (str, optional): X-axis label; omitted means no label.
        y_label (str, optional): Y-axis label; omitted means no label.
        legend_title (str, optional): Legend title.
        figsize (tuple[int, int], optional): Figure size, used only when ax is None.
        ax (Axes, optional): Axes to plot on.
        source_text (str, optional): Source attribution rendered at the bottom.
        move_legend_outside (bool, optional): Move the legend outside the plot.
        **kwargs: Forwarded to matplotlib scatter; s scales bubble area (default 2000) and
            edgecolor defaults to black.

    Raises:
        ValueError: If df is empty, value_col is not numeric, every row is missing value_col or
            group_col, or no data falls within the bins.
        KeyError: If value_col or group_col is not a column of df.
        TypeError: If bins is neither an int nor a list.

    """
    # Validate inputs and get clean data
    df_clean, bins = _validate_inputs(df, value_col, group_col, bins)

    # Create price bins
    df_clean["price_bin"] = pd.cut(df_clean[value_col], bins=bins, include_lowest=True)

    # Calculate percentage distribution for each group
    group_totals = df_clean.groupby(group_col, observed=True).size()
    bin_counts = df_clean.groupby([group_col, "price_bin"], observed=True).size().unstack(fill_value=0)

    # Convert to proportions (0-1 range)
    proportions = bin_counts.div(group_totals, axis=0)

    if ax is None:
        _, ax = plt.subplots(figsize=figsize)

    # Get unique groups and bins
    groups = proportions.index.tolist()
    price_bins = proportions.columns.tolist()

    # Set up color mapping
    colors = get_plot_colors(len(groups))

    alpha = kwargs.pop("alpha", 0.7)
    s_scale = kwargs.pop("s", 2000)
    edge_color = kwargs.pop("edgecolor", "black")  # black stroke around bubbles
    line_width = kwargs.pop("linewidth", 1.5)  # Stroke width

    # Validate that we have some data
    if proportions.max().max() == 0 or pd.isna(proportions.max().max()):
        raise ValueError("All proportions are zero - no data falls within the specified bins")

    # Melt to get all (group, price_bin) combinations with their proportions
    melted = proportions.reset_index().melt(id_vars=group_col, var_name="price_bin", value_name="proportion")
    # Filter out zero proportions to avoid invisible bubbles
    melted = melted[melted["proportion"] > 0]

    if len(melted) > 0:  # Only plot if there are non-zero proportions
        x_positions = [groups.index(group) for group in melted[group_col]]
        y_positions = [price_bins.index(price_bin) for price_bin in melted["price_bin"]]
        # Calculate bubble sizes using absolute proportion values for cross-group comparison
        bubble_sizes = (melted["proportion"] * s_scale).to_numpy()
        bubble_colors = [colors[groups.index(group)] for group in melted[group_col]]

        ax.scatter(
            x_positions,
            y_positions,
            s=bubble_sizes,
            c=bubble_colors,
            alpha=alpha,
            edgecolor=edge_color,
            linewidth=line_width,
            **kwargs,
        )

    ax.set_xticks(range(len(groups)))
    ax.set_xticklabels(groups)
    ax.set_yticks(range(len(price_bins)))

    # Reserve half a unit on each side to give bubbles breathing room even at high s_scale.
    ax.set_xlim(-0.5, len(groups) - 0.5)
    ax.set_ylim(-0.5, len(price_bins) - 0.5)

    # pd.cut(..., include_lowest=True) extends the lowest bin's left edge by ~0.1% of the data range below the minimum
    # so the minimum value is included; when that epsilon lands within rounding distance of zero, naive formatting
    # produces "-0.0", which is meaningless to readers.
    formatted_labels = [f"{_fmt_bin_edge(bin_.left)} - {_fmt_bin_edge(bin_.right)}" for bin_ in price_bins]

    ax.set_yticklabels(formatted_labels)

    # The single ax.scatter() call above draws all bubbles in one collection, so
    # matplotlib's legend auto-discovery has no per-group handles to find. Seed
    # one invisible labeled marker per group so standard_graph_styles can build
    # the legend, and so tight_layout can reserve room for it when
    # move_legend_outside=True. The bubble's edge stroke is omitted on the
    # proxies — at legend marker size the stroke would dominate and wash out
    # the fill color.
    if len(groups) > 1:
        for i, group in enumerate(groups):
            ax.scatter([], [], c=[colors[i]], alpha=alpha, linewidths=0, label=group)

    return standard_graph_styles(
        ax=ax,
        title=title,
        eyebrow=eyebrow,
        subtitle=subtitle,
        x_label=x_label,
        y_label=y_label,
        legend_title=legend_title,
        move_legend_outside=move_legend_outside,
        show_legend=len(groups) > 1,
        source_text=source_text,
        grid_axis="y",
    )