On this page

Relationships between variables

Two measured variables per observation call for a different set of plots: densities of many points, fitted lines, agreement between two methods, and grids of every pair. All of them compute in standard-library Python; numpy is not needed.

All values below are illustrative simulated observations. Each snippet can be run independently with core Inklet and writes an SVG and a PDF. The fits and limits are descriptive: no test is run beyond what each section states, and the numbers are there to report, not to replace a statistical analysis.

2D histograms and hexbins

When points overlap, count them. hist2d counts points in rectangular bins on continuous axes; hexbin counts them in hexagons that are regular on the page, on log axes too. Both colour each bin by its count through a magma ramp without its palest end, so a bin of one still shows, and colorbar() explains the colours. log=True colours by log count, and min_count leaves sparse bins empty. inklet.plot.histogram2d returns the edges and counts without drawing.

import inklet as i
import random
rng = random.Random(501)
points = []
for _ in range(4000):
    x = rng.gauss(0, 1.1)
    points.append((x, .6 * x + rng.gauss(0, .8)))
hexes = i.plot_spec(x=(-4, 4), y=(-4, 4), height=40)
hexes.hexbin(points, gridsize=22)
hexes.axes(x='Score A', y='Score B')
hexes.colorbar(label='Cells')
cells = i.plot_spec(x=(-4, 4), y=(-4, 4), height=40)
cells.hist2d(points, bins=18)
cells.axes(x='Score A', y='Score B')
cells.colorbar(label='Cells')
doc = i.document(width=130, columns=[1, 1], gap=8)
doc.add('hexes', hexes, row=0, column=0)
doc.add('cells', cells, row=0, column=1)
doc.save('hexbin.svg', 'hexbin.pdf')

Hexbin and 2D histogram of 4,000 correlated points, each with a count colour bar.

Rendered from the code above: the same 4,000 simulated points counted in hexagons (left) and rectangles (right).

Density-coloured scatter

density_scatter keeps every point and colours it by how crowded its neighbourhood is. It estimates the density on the page, so it behaves the same on log axes and follows where the marks actually overlap. The densest points are drawn last. With raster=True the markers are one embedded image, which keeps 100,000 points small in SVG and PDF while the axes and text stay vector. Colours run from 0 to the densest point, so the colour bar reads as relative density.

import inklet as i
import math
import random
rng = random.Random(502)
points = []
for _ in range(100_000):
    u = rng.gauss(0, 1)
    points.append((math.exp(1 + u), math.exp(.5 + .7 * u + .6 * rng.gauss(0, 1))))
p = i.plot_spec(x=i.plot.log((.01, 1000)), y=i.plot.log((.01, 1000)), height=44)
p.density_scatter(points, raster=True, size=.5)
p.axes(x='Intensity A / a.u.', y='Intensity B / a.u.')
p.colorbar(label='Relative density')
doc = i.document(width=80)
doc.add('density', p)
doc.save('density.svg', 'density.pdf')

Density-coloured scatter of 100,000 points on log axes, rasterised, with a colour bar.

Rendered from the code above: 100,000 simulated log-normal intensities in one embedded image.

2D density contours

kde2d draws contours of a Gaussian kernel density. Each contour encloses a share of the estimated probability: levels=(.5, .8, .95) draws the smallest regions that hold 50%, 80% and 95% of it, and levels=5 draws five evenly spaced shares up to 95%. fill=True fills the regions between them from the density ramp, palest outside. The bandwidth per axis follows Scott's rule (sd * n^-1/6) unless given. inklet.plot.kde2d returns the density lattice.

import inklet as i
import random
rng = random.Random(503)
points = []
for _ in range(900):
    centre = (-1.2, -.8) if rng.random() < .55 else (1.4, 1.1)
    points.append((rng.gauss(centre[0], .8), rng.gauss(centre[1], .7)))
lines = i.plot_spec(x=(-4, 4), y=(-4, 4), height=40)
lines.scatter(points, size=.5, color='#bbbbbb')
lines.kde2d(points, levels=(.25, .5, .75, .95))
lines.axes(x='Component 1', y='Component 2')
filled = i.plot_spec(x=(-4, 4), y=(-4, 4), height=40)
filled.kde2d(points, levels=6, fill=True)
filled.axes(x='Component 1', y='Component 2')
doc = i.document(width=110, columns=[1, 1], gap=8)
doc.add('lines', lines, row=0, column=0)
doc.add('filled', filled, row=0, column=1)
doc.save('kde2d.svg', 'kde2d.pdf')

2D kernel density contours over points, and the same density as filled levels.

Rendered from the code above. The contours hold 25%, 50%, 75% and 95% of the estimated density of 900 simulated points from two clusters.

Contours of a gridded field

contour(x, y, z, ...) draws lines through a field that is already sampled on a grid, such as the counts of a 2D histogram or a simulated surface. z[j][i] is the value at (x[i], y[j]), the same layout as matrix(). Give the values in levels=, or give the shares of the total that the regions should hold in hdr=: hdr=(0.683, 0.954) draws the highest-density regions holding 68.3% and 95.4% of the counts. inklet.plot.mass_levels(z, (0.683, 0.954)) returns the values behind those regions for a grid, without drawing.

import inklet as i
import random
from inklet.plot import histogram2d
rng = random.Random(611)
points = [(rng.gauss(0, 1.1), rng.gauss(0, .8)) for _ in range(1200)]
x_edges, y_edges, counts = histogram2d(points, bins=(16, 16), range=((-3, 3), (-3, 3)))
x = [(a + b) / 2 for a, b in zip(x_edges, x_edges[1:])]
y = [(a + b) / 2 for a, b in zip(y_edges, y_edges[1:])]
p = i.plot_spec(x=(-3, 3), y=(-3, 3), height=60)
p.contour(x, y, counts, hdr=(0.683, 0.954), color=['#24698c', '#b8512e'])
p.axes(x='Component 1', y='Component 2')
doc = i.document(width=110)
doc.add('contours', p)
doc.save('contours.svg', 'contours.pdf')

Contours follow a log axis as the axis bends, and the lines are cut to the plot area unless clip=False. A missing value (None) lies outside every level, so a contour closes round a gap instead of through it. A level outside the range of z draws nothing, and if none is inside it the call fails.

Regression and residuals

regression draws the points, a least-squares line and its 95% confidence band for the mean response, from Student's t on n - 2 degrees of freedom. prediction=True shades the wider interval for a new observation instead. method='lowess' draws R's lowess() smoother. The line's node carries a regression note with the slope, intercept, R² and p-value; compute them with inklet.plot.linear_fit, which matches R's lm on the cars data. On a log x axis the fit is of y on log10 x, so the line stays straight. residuals draws what the fit left, against x, with a zero line. smooth=True adds a LOWESS trend.

import inklet as i
from inklet.plot import linear_fit
speed = [4, 4, 7, 7, 8, 9, 10, 10, 10, 11, 11, 12, 12, 12, 12, 13, 13, 13, 13,
         14, 14, 14, 14, 15, 15, 15, 16, 16, 17, 17, 17, 18, 18, 18, 18, 19,
         19, 19, 20, 20, 20, 20, 20, 22, 23, 24, 24, 24, 24, 25]
dist = [2, 10, 4, 22, 16, 10, 18, 26, 34, 17, 28, 14, 20, 24, 28, 26, 34, 34,
        46, 26, 36, 60, 80, 20, 26, 54, 32, 40, 32, 40, 50, 42, 56, 76, 84, 36,
        46, 68, 32, 48, 52, 56, 64, 66, 54, 70, 92, 93, 120, 85]
cars = list(zip(speed, dist))
fit = linear_fit(cars)
line = i.plot_spec(x=(0, 26), y=(-20, 125), height=40)
line.regression(cars)
line.axes(x='Speed / mph', y='Stopping distance / ft')
line.text(1, 110, f'R² = {fit.r2:.2f}', anchor='w')
left = i.plot_spec(x=(0, 26), y=(-40, 50), height=40)
left.residuals(cars, smooth=True)
left.axes(x='Speed / mph', y='Residual / ft')
doc = i.document(width=120, columns=[1, 1], gap=8)
doc.add('fit', line, row=0, column=0)
doc.add('residuals', left, row=0, column=1)
doc.save('regression.svg', 'regression.pdf')

Linear regression with a confidence band on the cars data, and its residuals with a LOWESS trend.

Rendered from R's cars data (Ezekiel, 1930). The residuals widen with speed, which the straight fit does not model.

Bland-Altman agreement

bland_altman compares two methods that measure the same subjects. Each pair is drawn at its mean and its difference. A solid line marks the bias (the mean difference) and dashed lines the limits of agreement, bias ± 1.96 SD, with each value set in the right margin clear of the data. confidence=0.95 shades the confidence interval of the bias and of each limit (Bland and Altman, 1986). percent=True plots differences as a percentage of the mean. inklet.plot.bland_altman(a, b) returns the same numbers, which set the y domain.

import inklet as i
import random
from inklet.plot import bland_altman
rng = random.Random(504)
truth = [rng.uniform(60, 180) for _ in range(80)]
meter = [t + rng.gauss(2.5, 4.5) for t in truth]
lab = [t + rng.gauss(0, 3.0) for t in truth]
agreement = bland_altman(meter, lab)
p = i.plot_spec(x=(50, 190), y=(-25, 30), height=40)
p.bland_altman(meter, lab, confidence=.95, format='{:.1f}')
p.axes(x='Mean of methods / mg dl⁻¹', y='Meter − lab / mg dl⁻¹')
doc = i.document(width=90)
doc.add('bland-altman', p)
doc.save('bland-altman.svg', 'bland-altman.pdf')

Bland-Altman plot with the bias, limits of agreement and their confidence intervals labelled in the margin.

Rendered from the code above: 80 simulated paired glucose readings.

Pair plots

inklet.pairplot draws every pair of variables in one aligned grid. Each column shares an x scale and each row a y scale, and only the outer panels write numbers and names. The diagonal shows each variable's distribution. The lower and upper triangles draw 'scatter', 'kde2d', 'hexbin', 'hist2d', 'regression' or nothing. groups= colours observations by label and keys them. The diagonal then shows per-group densities, which overlap more legibly than histograms. corner=True drops the upper triangle. The result is a diagram, so it goes into row, column or a document like a panel.

import inklet as i
import random
rng = random.Random(505)
species, data = [], {'Sepal length': [], 'Sepal width': [], 'Petal length': []}
for name, (a, b, c) in {'setosa': (5.0, 3.4, 1.5), 'versicolor': (5.9, 2.8, 4.3),
                        'virginica': (6.6, 3.0, 5.5)}.items():
    for _ in range(50):
        species.append(name)
        data['Sepal length'].append(rng.gauss(a, .4))
        data['Sepal width'].append(rng.gauss(b, .3))
        data['Petal length'].append(rng.gauss(c, .45))
grid = i.pairplot(data, groups=species, cell=24)
doc = i.document(width=125)
doc.add('pairs', grid)
doc.save('pairs.svg', 'pairs.pdf')

Pair plot of three variables for three groups, with densities on the diagonal.

Rendered from the code above: simulated measurements in the spirit of Anderson's iris data, 50 per group.

Joint plots

inklet.jointplot draws one pair with both marginal distributions along the top and right edges, on the main panel's own scales. The main panel draws kind ('scatter', 'kde2d', 'hexbin', 'hist2d' or 'regression') and the margins marginal='hist' or 'kde'. width is the main panel's side and ratio its size relative to the margins. The margins carry no count axis, since only their shape is read.

import inklet as i
import random
rng = random.Random(506)
points = []
for _ in range(3000):
    x = rng.gauss(0, 1)
    points.append((x, .55 * x + rng.gauss(0, .85)))
joint = i.jointplot(points, kind='hexbin', marginal='kde', width=46,
                    x_label='Score A', y_label='Score B')
doc = i.document(width=80)
doc.add('joint', joint)
doc.save('joint.svg', 'joint.pdf')

Joint plot: a hexbin of 3,000 points with density curves along the top and right.

Rendered from the code above.

Next steps

See distributions for one variable at a time and uncertainty for supplied intervals. For exact options, see the API.

Search documentation

Type to search guides, examples and the API.