Scatter Plots

scatter() versus plot(), and the two extra dimensions you get for free.

Overview

Two ways to draw dots

A scatter plot shows the relationship between two numeric variables. Each point is one observation. The pattern of points -- its shape, its spread, its density -- tells you whether the variables are related, and how.

Scatter plots are the standard chart for correlation. A tight diagonal cloud suggests a linear relationship. A curve suggests a nonlinear one. A uniform cloud suggests none. No other chart type shows this as directly.

matplotlib offers two ways to draw scatter points, and they are not interchangeable. ax.plot with a marker style is faster and simpler when every point looks the same. ax.scatter is slower but lets you map a third and fourth variable to size and colour. Choosing the right one depends on what the chart needs to show.

This page covers both methods, sizing, colour mapping, overplotting, trend lines, category colouring, bubble charts, jitter for discrete values and density alternatives for large datasets. It also covers the practical defaults that make a scatter plot readable: alpha for overlap, edge colours for separation, and colour bars for mapped variables.

Overplotting is the main problem with scatter plots at scale. A thousand points on a small axes overlap into a solid blob. The page covers several fixes: reducing alpha, reducing marker size, hexbin plots, 2-D histograms and kernel density contours. Each trades detail for clarity at a different point.

When adding a trend line, remember that the line is a model. A straight line through a curved cloud is misleading. A polynomial through noise is overfit. The trend line should match what you know about the relationship.

ax.plot(x, y, "o") draws markers with no connecting line. ax.scatter(x, y) draws a set of points.

They look same for a simple case, and they differ in what can vary.

plot draws one line object: each marker is the same size and the same colour. That uniformity makes it faster, at times much, for large numbers of same points.

scatter draws a PathCollection where size and colour can be arrays, one value per point. That is what makes it a four-dimensional display: x, y, size and colour.

The rule of thumb: plot when the points are all alike, scatter when they aren't. The examples below show both so you can compare them side by side.

Worth knowing

scatter and plot(x, y, "o") look alike; only scatter can vary size and colour per point.
s is area in points squared, so doubling the width means four times s — which is right, because area is what the eye compares.
c= an array is mapped through a colormap; color= a name is one fixed colour. The two are easily confused.
With thousands of points a scatter becomes a silhouette — alpha shows density and hexbin measures it.
edgecolor separates overlapping points; marker shape is a weak encoding beyond about three categories.
A trend line is np.polyfit plus an ordinary plot, drawn only over the observed range.

Scatter Plots

scatter() versus plot(), and the two extra dims you get for free.

scatter and plot draw the same dots differently

One makes a collection, the other makes a line with markers and no line.

example_01.pymatplotlib
Output

Size as a third variable

s is in points squared, so it scales with area rather than radius.

example_02.pymatplotlib
Output

Colour as a fourth

c takes an array, and then a colorbar explains it.

example_03.pymatplotlib
Output

Overplotting hides the data

With enough points, a scatter becomes a silhouette.

example_04.pymatplotlib
Output

Marker shape, edges and transparency

The arguments that make a dense scatter readable.

example_05.pymatplotlib
Output

Adding a trend line

Two lines of numpy, and the result is drawn like any other line.

example_06.pymatplotlib
Output

Size

s is the marker area in points squared.

That trips people, because doubling s doesn't double the visual width. It takes four times s to double the width. The default is around 36, meaning six points across.

Area is the right thing to scale. The eye checks areas rather than radii. A common mistake is passing a raw value as s and getting circles that differ far more than the underlying numbers do. Scaling into a sensible range first is often needed.

Very small values disappear and very large ones share into a mass. The handy range is narrow. Roughly 10 to 400 for most plots.

Colour

c= takes an array of values. This Are mapped through a colormap and can be shown with a colorbar.

color= takes a single colour applied to each point.

The names are one letter apart and the failure is silent. Passing an array to color either errors or makes a thing unintended. Passing a single colour to c works but wastes the method. "Why is my whole plot one colour" is nearly always this.

fig.colorbar(sc, ax=ax) needs the object scatter returned. This Is why the return value is captured here where it's skipped elsewhere.

Overplotting

A scatter with a few hundred points shows the data. With a few thousand it shows the outline of the data. Each point in the middle is hidden behind another.

Three responses, in order of how much they preserve:

alpha below 1 makes density visible as darkness. It's the cheapest fix and works up to tens of thousands of points.

Smaller markers help, and combine with alpha.

hexbin or hist2d bins the points and shows counts. This Turns density from a thing implied into a thing measured. Past roughly fifty thousand points this is the only honest option. It's also far faster to draw.

Sampling is a fourth option, and a fair one when the shape matters more than the completeness.

Making it readable

edgecolor="black" with a thin linewidth outlines each marker. This Splits points that share. It's the single most good small gain to a moderately dense scatter.

Marker shape sets apart groups, and does so weakly. Readers well tell apart about three shapes, not six. Colour and spot are much stronger encodings. If you need six groups, small multiples often beat six shapes on one plot.

Trend lines

np.polyfit(x, y, 1) returns slope and intercept. The fit is then drawn with an plain plot.

Two details worth getting right. Draw the line only over the range of the seen x values. It doesn't imply the link holds beyond the data. And put the coeffs in the legend label. So The chart carries the number rather than needing the reader to guess it.

For anything beyond a straight line, numpy.polynomial is the modern interface and SciPy or statsmodels give trust intervals. At which point the fit is a statistical claim and deserves the nearby machinery.

Colour by category

For a cat var, plotting one scatter per group is clearer than mapping colours by hand:

for name, sub in groups.items():
    ax.scatter(sub.x, sub.y, label=name, alpha=0.7)
ax.legend()

Each call takes the next colour from the cycle and the legend is built from the labels. Nothing has to be assembled manually.

Passing a list of group codes to c= works and gives you a smooth colormap applied to any ints. This Implies an sorting the groups don't have. The same mistake as using a sequential colormap for groups.

Bubble charts

Encoding a third var in the marker area makes a bubble chart. It has a exact weakness. Area is read poorly. The third var is the least accurately seen thing on the chart.

Two rules make them work when they're used.

Scale by area, not by radius. Passing the raw value as s scales by area already. s is area. But finding a radius and squaring it's a common way to exaggerate differences fourfold.

Provide a size legend, since no one can read an area off a chart without a link. ax.legend can be built from proxy artists at a few representative sizes.

If the third var is the point of the chart, spot or colour will carry it better than size.

Jitter for discrete values

When one axis is discrete. A rating from one to five, a day of the week — points land just on top of each other and the density is invisible.

Adding a small random offset splits them:

xj = x + rng.uniform(-0.15, 0.15, len(x))

The jitter must be small enough not to blur the groups. It should be mentioned if the chart is published. It's a deliberate distortion of spot.

alpha and marker size do part of the same job and don't move anything.

Connecting scatter points

ax.plot(x, y, "-o") connects points in the order they appear in the array. This For a scatter is often meaningless and at times just right.

The case where it's right is a connected scatter. Two vars measured over time, with the line showing the path through the space. Unemployment against inflation year by year, say. Adding an arrow or labelling the first and last point makes the direction readable.

For anything without a natural order, sorting by x before connecting is what turns it into a line chart. Doing it accidentally. Because the data happened to arrive sorted. Makes a chart that implies a sequence that doesn't exist.

Density alternatives, in order

As n grows, the sequence of fair displays is fairly fixed:

Under 1,000 — a plain scatter, with edgecolor for split.

1,000 to 20,000 — alpha around 0.1–0.3, smaller markers.

20,000 to 500,000 — hexbin or hist2d. This Measure density rather than implying it.

Above that — datashader, or aggregate before plotting.

At each stage the question is the same. Can the reader see the middle of the spread, or only its outline? If only the outline, the display has stopped working even so of how many points are strictly drawn.

Correlation and what a scatter shows

A scatter is the display for a link. It shows more than a link coeff does.

It shows the shape — linear, curved, stepped — where a coeff assumes linearity.

It shows outliers, which can create or destroy a link on their own.

It shows clusters, which a single coeff means away. Which often mean an unmodelled group var.

It shows heteroscedasticity — spread that changes with x — which invalidates a few standard tests.

Anscombe's quartet is the canonical demo: four datasets with same means, variances and link. Four fully other scatters. Plotting first isn't a formality.

Practical defaults

A scatter that works most of the time:

ax.scatter(x, y, s=25, alpha=0.6, edgecolor="white", linewidth=0.4)

Moderate size, some transparency, and a thin light edge that splits shared points without adding visual weight.

From there the adjustments follow the data: lower alpha and smaller markers as n grows, hexbin when the middle stops being visible, colour when there's a third var worth showing.

For a link that will be discussed, adding the fit and putting its slope in the legend saves the reader guessing it. And forces you to look at whether a straight line is the right model. This The scatter will have already told you.

Scatter Plots, in summary

scatter when size or colour varies per point, plot with a marker when they don't. The second is faster and there's nothing to gain from the machinery you aren't using.

s is area, so it scales as the square of the visual width.

c= takes an array through a colormap. color= takes one colour. The gap is one letter and makes very other charts.

Overplotting is the recurring problem, and the sequence of answers is alpha, then smaller markers, then hexbin, then aggregation before plotting.

edgecolor splits shared points and is the cheapest readability fix on hand.

And a scatter shows things a link coeff can't. Shape, clusters, outliers and changing spread. That is the reason to draw one before finding anything.

Adding a third dimension well

When a scatter needs to carry a third var, the options aren't equally good.

Colour, sequential — for a smooth third var. Read fairly well, and needs a colorbar.

Colour, cat — for a few discrete levels. Works up to about five before the colours stop separating.

Small multiples — one panel per level of a cat var. Better than colour for anything above three levels. Checking panels is easier than separating shared colours.

Size — read poorly, and best reserved for a var that is context rather than the subject.

Shape — read poorly beyond about three levels.

The pattern is that the two strong channels. Spot and panel. Are already used or on hand. The weak ones should carry the least key var. Encoding the most key third var as size is a common inversion of that.

Labelling points

A scatter of named entities. Countries, products, customers — often wants some of the points labelled.

Labelling all of them makes an unreadable mass. The handy approaches:

Label the extremes, which are what the reader will ask about: the highest, the lowest, the furthest from the fit.

Label a chosen few that the pairing text discusses.

Label on hover, which matplotlib doesn't do well and is a reason to use an interactive library when it matters.

For the first two, offsetting the text so it doesn't sit on the marker is needed:

ax.annotate(name, (x[i], y[i]), textcoords="offset points",
            xytext=(5, 4), fontsize=9)

With more than about eight labels, shares become the problem. adjustText is the usual third-party answer. Or, more simply, labelling fewer points.

One more thing

ax.scatter returns a PathCollection, and set_offsets updates the point spots without redrawing all.

That is what makes an animated or interactive scatter fast. It's the same reuse principle as set_ydata on a line: create the artist once, change its data.

Scatter Plots: the short version

A scatter is the display that shows what a summary statistic can't: shape, clusters, outliers and changing spread.

Its failure mode is density, and the sequence of fixes is fixed. Alpha, smaller markers, hexbin, aggregation. The question at each stage is whether the middle of the spread is still visible.

Questions people ask about Scatter Plots

What is the difference between ax.scatter and ax.plot with markers? scatter can vary size and colour per point. plot draws one style for all points and is much faster for large arrays.

How do I handle thousands of overlapping points? Lower alpha, shrink the markers, or switch to hexbin or a 2D histogram. All three show density that overplotting hides.

How do I colour points by a third variable? Pass an array to c= and a colormap to cmap=, then add a colorbar so readers can decode the colours.

How do I add a trend line? Fit it yourself, for example with np.polyfit, and draw it with ax.plot. matplotlib does not fit models for you.

What does the s argument mean? It is marker area in points squared, not radius. Doubling s does not double the visible width.

Does a tight scatter prove one variable causes the other? No. A scatter shows association only. A third variable or the direction of causation can produce the same pattern.

Reading the code back

A working scatter is often four choices: marker size, transparency, whether an edge is needed. Whether a third var is being coded. all else follows from the data. The size and alpha are chosen from the number of points rather than from taste. This Is why the same two lines that work for two hundred points fail for twenty thousand. Why the sequence of density fixes in this module is worth having in mind before drawing rather than after.

Check yourself

0 of 4

Answer without scrolling back up.

  1. When does `scatter` do something `plot(x, y, 'o')` cannot?

  2. What does `s=400` mean compared with `s=100`?

  3. What is the difference between `c=` and `color=` in scatter?

  4. 5000 points produce a solid blob. What is the most honest fix?

Cheat sheet

Scatter Plots

Build scatter plots with matplotlib's scatter function. Map colour and size to data columns and encode four dimensions in one chart.

MATPLOTLIB · vizlearn.in/matplotlib/scatter_plots.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.