Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added
- Support `marginal_x`/`marginal_y="heatmap"` in `density_heatmap`/`density_contour`, drawing a single-row/column heatmap strip in the margin colored by the same `z`/`histfunc` aggregate as the main plot; `text_auto` now also applies to the marginal heatmap strip [[#5706](https://github.com/plotly/plotly.py/issues/5706)]

### Fixed
- Fix `hex_to_rgb` parsing of 3-digit shorthand hexadecimal colors such as `#FFF` [[#5662](https://github.com/plotly/plotly.py/pull/5662)], with thanks to @genrichez for the contribution!
- Add `<!doctype html>` to the `to_html()` template to comply with modern web standards [[#5693](https://github.com/plotly/plotly.py/pull/5693)], with thanks to @mishrakushal for the contribution!
Expand Down
23 changes: 22 additions & 1 deletion doc/python/marginal-plots.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Marginal distribution plots are small subplots above or to the right of a main p

### Scatter Plot Marginals

The `marginal_x` and `marginal_y` arguments accept one of `"histogram"`, `"rug"`, `"box"`, or `"violin"` (see also how to create [histograms](/python/histograms/), [box plots](/python/box-plots/) and [violin plots](/python/violin-plots/) as the main figure).
The `marginal_x` and `marginal_y` arguments accept one of `"histogram"`, `"rug"`, `"box"`, or `"violin"` (see also how to create [histograms](/python/histograms/), [box plots](/python/box-plots/) and [violin plots](/python/violin-plots/) as the main figure), plus `"heatmap"` for `density_heatmap` and `density_contour` (see below).

Marginal plots are linked to the main plot: try zooming or panning on the main plot.

Expand All @@ -59,6 +59,27 @@ fig = px.density_heatmap(df, x="sepal_length", y="sepal_width", marginal_x="box"
fig.show()
```

### Marginal Heatmaps on Density Heatmaps and Contours

`marginal_x` and `marginal_y` also accept `"heatmap"` for [`density_heatmap`](/python/2D-Histogram/) and [`density_contour`](/python/2d-histogram-contour/). This draws a single-row or single-column heatmap strip, colored by the same aggregate (`histfunc` of `z`, or count by default) as the main plot, and sharing its color scale.

```python
import plotly.express as px
df = px.data.tips()
fig = px.density_heatmap(df, x="total_bill", y="tip", marginal_x="heatmap", marginal_y="heatmap")
fig.show()
```

Set `text_auto=True` to display the aggregate value as text on both the main plot and the marginal heatmap strips, or pass a [d3-format](https://github.com/d3/d3-format) string such as `".2f"` to control the number of decimal places:

```python
import plotly.express as px
df = px.data.tips()
fig = px.density_heatmap(df, x="total_bill", y="tip", z="size", histfunc="avg",
marginal_x="heatmap", marginal_y="heatmap", text_auto=".1f")
fig.show()
```

### Marginal Plots and Color

Marginal plots respect the `color` argument as well, and are linked to the respective legend elements. Try clicking on the legend items.
Expand Down
20 changes: 20 additions & 0 deletions plotly/express/_chart_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ def density_contour(
"For `density_heatmap` and `density_contour` these values are used as the inputs to `histfunc`.",
],
histfunc=["The arguments to this function are the values of `z`."],
marginal_x=[
"Also supports `'heatmap'`, showing a single-row heatmap colored by the aggregate value. "
"Uses a default colorscale since `density_contour` has no `color_continuous_scale` argument.",
],
marginal_y=[
"Also supports `'heatmap'`, showing a single-column heatmap colored by the aggregate value. "
"Uses a default colorscale since `density_contour` has no `color_continuous_scale` argument.",
],
text_auto=[
"Also applies to `marginal_x`/`marginal_y='heatmap'`, in which case the z values are always displayed.",
],
),
)

Expand Down Expand Up @@ -214,6 +225,15 @@ def density_heatmap(
histfunc=[
"The arguments to this function are the values of `z`.",
],
marginal_x=[
"Also supports `'heatmap'`, showing a single-row heatmap colored by the aggregate value.",
],
marginal_y=[
"Also supports `'heatmap'`, showing a single-column heatmap colored by the aggregate value.",
],
text_auto=[
"Also applies to `marginal_x`/`marginal_y='heatmap'`, in which case the z values are always displayed.",
],
),
)

Expand Down
64 changes: 58 additions & 6 deletions plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,12 +971,55 @@ def make_trace_spec(args, constructor, attrs, trace_patch):
),
marginal=letter,
)
elif args["marginal_" + letter] == "heatmap":
if constructor not in [go.Histogram2d, go.Histogram2dContour]:
raise ValueError(
"`marginal_x`/`marginal_y` value `'heatmap'` is only supported "
"for `density_heatmap` and `density_contour`."
)
other_letter = "y" if letter == "x" else "x"
heatmap_trace_patch = dict(
coloraxis="coloraxis1", histfunc=args.get("histfunc"), **axis_map
)
# `nbinsx`/`nbinsy` are only a target bin count -- plotly.js's "nice
# number" bin-sizing can still round to more than one bin. Force
# exactly one bin by setting explicit bin edges covering the data.
other_col = args["data_frame"].get_column(args[other_letter])
other_min = nw.to_py_scalar(other_col.min())
other_max = nw.to_py_scalar(other_col.max())
span = (other_max - other_min) or 1
pad = span * 0.001
other_bins = dict(
start=other_min - pad, end=other_max + pad, size=span + 2 * pad
)
if letter == "x":
heatmap_trace_patch["xbingroup"] = "x"
heatmap_trace_patch["ybins"] = other_bins
else:
heatmap_trace_patch["ybingroup"] = "y"
heatmap_trace_patch["xbins"] = other_bins
if args.get("text_auto", False) is not False:
if args["text_auto"] is True:
heatmap_trace_patch["texttemplate"] = "%{z}"
else:
heatmap_trace_patch["texttemplate"] = (
"%{z:" + args["text_auto"] + "}"
)
trace_spec = TraceSpec(
constructor=go.Histogram2d,
attrs=[letter, other_letter, "z"],
trace_patch=heatmap_trace_patch,
marginal=letter,
)
else:
raise ValueError(
f"Invalid value '{args['marginal_' + letter]}' for `marginal_{letter}`. "
"Supported marginal plot types are: 'rug', 'box', 'violin', 'histogram'."
"Supported marginal plot types are: "
"'rug', 'box', 'violin', 'histogram', 'heatmap'."
)
if "color" in attrs or "color" not in args:
if trace_spec.constructor != go.Histogram2d and (
"color" in attrs or "color" not in args
):
if "marker" not in trace_spec.trace_patch:
trace_spec.trace_patch["marker"] = dict()
first_default_color = args["color_continuous_scale"][0]
Expand Down Expand Up @@ -2337,6 +2380,10 @@ def infer_config(args, constructor, trace_patch, layout_patch):
if constructor in [go.Histogram2d, go.Densitymap, go.Densitymapbox]:
show_colorbar = True
trace_patch["coloraxis"] = "coloraxis1"
elif constructor == go.Histogram2dContour and (
args.get("marginal_x") == "heatmap" or args.get("marginal_y") == "heatmap"
):
show_colorbar = True

if "opacity" in args:
if args["opacity"] is None:
Expand Down Expand Up @@ -2630,6 +2677,10 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
trace_spec.constructor in [go.Histogram]
and m.variable in ["symbol", "dash"]
)
or (
trace_spec.constructor == go.Histogram2d
and m.variable in ["symbol", "pattern", "dash", "color"]
)
):
pass
elif (
Expand Down Expand Up @@ -2727,17 +2778,18 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
if show_colorbar:
colorvar = (
"z"
if constructor in [go.Histogram2d, go.Densitymap, go.Densitymapbox]
if constructor
in [go.Histogram2d, go.Histogram2dContour, go.Densitymap, go.Densitymapbox]
else "color"
)
range_color = args["range_color"] or [None, None]
range_color = args.get("range_color") or [None, None]

colorscale_validator = ColorscaleValidator("colorscale", "make_figure")
coloraxis_dict = dict(
colorscale=colorscale_validator.validate_coerce(
args["color_continuous_scale"]
args.get("color_continuous_scale")
),
cmid=args["color_continuous_midpoint"],
cmid=args.get("color_continuous_midpoint"),
cmin=range_color[0],
cmax=range_color[1],
colorbar=dict(
Expand Down
110 changes: 110 additions & 0 deletions tests/test_optional/test_px/test_marginals.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,116 @@ def test_single_marginals(backend, px_fn, marginal, orientation):
assert len(fig.data) == 1 + (marginal is not None)


@pytest.mark.parametrize("px_fn", [px.density_heatmap, px.density_contour])
def test_marginal_heatmap_uses_z_and_histfunc(backend, px_fn):
df = px.data.tips(return_type=backend)
# backend-independent reference for min/max, since e.g. pyarrow columns don't
# support .min()/.max() directly
pdf = px.data.tips()

fig = px_fn(
df,
x="total_bill",
y="tip",
z="size",
histfunc="sum",
marginal_x="heatmap",
marginal_y="heatmap",
)
assert len(fig.data) == 3
marginal_x_trace, marginal_y_trace = fig.data[1], fig.data[2]

assert marginal_x_trace.type == "histogram2d"
assert marginal_x_trace.coloraxis == "coloraxis"
assert marginal_x_trace.histfunc == "sum"
# a single bin covering the full y range, so the strip is exactly one row
assert marginal_x_trace.ybins.start <= pdf["tip"].min()
assert marginal_x_trace.ybins.end >= pdf["tip"].max()
assert marginal_x_trace.ybins.size >= pdf["tip"].max() - pdf["tip"].min()

assert marginal_y_trace.type == "histogram2d"
assert marginal_y_trace.coloraxis == "coloraxis"
assert marginal_y_trace.histfunc == "sum"
# a single bin covering the full x range, so the strip is exactly one column
assert marginal_y_trace.xbins.start <= pdf["total_bill"].min()
assert marginal_y_trace.xbins.end >= pdf["total_bill"].max()
assert marginal_y_trace.xbins.size >= pdf["total_bill"].max() - pdf["total_bill"].min()

assert fig.layout.coloraxis.colorbar.title.text == "sum of size"


@pytest.mark.parametrize("px_fn", [px.density_heatmap, px.density_contour])
def test_marginal_heatmap_without_z(backend, px_fn):
df = px.data.tips(return_type=backend)

fig = px_fn(
df, x="total_bill", y="tip", marginal_x="heatmap", marginal_y="heatmap"
)
marginal_x_trace, marginal_y_trace = fig.data[1], fig.data[2]

assert marginal_x_trace.type == "histogram2d"
assert marginal_x_trace.coloraxis == "coloraxis"
assert marginal_x_trace.histfunc is None

assert marginal_y_trace.type == "histogram2d"
assert marginal_y_trace.coloraxis == "coloraxis"
assert marginal_y_trace.histfunc is None

assert fig.layout.coloraxis.colorbar.title.text == "count"


@pytest.mark.parametrize("px_fn", [px.density_heatmap, px.density_contour])
@pytest.mark.parametrize("text_auto", [True, ".1f"])
def test_marginal_heatmap_text_auto(backend, px_fn, text_auto):
df = px.data.tips(return_type=backend)

fig = px_fn(
df,
x="total_bill",
y="tip",
marginal_x="heatmap",
marginal_y="heatmap",
text_auto=text_auto,
)
expected = "%{z}" if text_auto is True else "%{z:" + text_auto + "}"
for trace in fig.data:
assert trace.texttemplate == expected


@pytest.mark.parametrize("px_fn", [px.density_heatmap, px.density_contour])
def test_marginal_heatmap_no_text_auto(backend, px_fn):
df = px.data.tips(return_type=backend)

fig = px_fn(
df, x="total_bill", y="tip", marginal_x="heatmap", marginal_y="heatmap"
)
for trace in fig.data:
assert trace.texttemplate is None


def test_marginal_heatmap_unsupported_chart_type_raises():
with pytest.raises(ValueError, match="only supported for `density_heatmap`"):
px.scatter(x=[1, 2, 3], y=[2, 3, 4], marginal_x="heatmap")
with pytest.raises(ValueError, match="only supported for `density_heatmap`"):
px.scatter(x=[1, 2, 3], y=[2, 3, 4], marginal_y="heatmap")
with pytest.raises(ValueError, match="only supported for `density_heatmap`"):
px.histogram(x=[1, 2, 3], marginal="heatmap")


def test_marginal_heatmap_with_discrete_color(backend): # density_contour + line.color
df = px.data.tips(return_type=backend)
fig = px.density_contour(
df, x="total_bill", y="tip", color="sex", marginal_x="heatmap"
)
assert len(fig.data) == 4
assert [t.type for t in fig.data] == [
"histogram2dcontour",
"histogram2d",
"histogram2dcontour",
"histogram2d",
]


def test_unsupported_marginal_raises_clear_error(): # issue 4654
# An unsupported marginal type used to fail deep inside make_figure with a
# cryptic "'NoneType' object has no attribute 'constructor'". It should
Expand Down
Loading