Principal component analysis
The direction the points spread out most (teal), and the one at right angles to it (amber). Drag the points.
pie.clear()
pie.scalar(0, out='gridOpacity')
import numpy as np
coords = np.random.default_rng().multivariate_normal([0, 0], [[4, 2.4], [2.4, 2]], 14).clip(-4, 4).round(2)
pts = [pie.point(x, y, fill='crimson') for x, y in coords]
def directions(ps):
"""The spread along each direction, largest first, and the directions as columns."""
spreads, dirs = np.linalg.eigh(np.cov(np.array(ps).T))
return spreads[::-1], dirs[:, ::-1]
def average(*ps):
return tuple(np.mean(ps, axis=0))
def axis_end(which, sign, *ps):
"""One end of direction `which` (0: first, 1: second), 2 standard deviations from the average point."""
spreads, dirs = directions(ps)
return tuple(np.mean(ps, axis=0) + sign * 2 * np.sqrt(spreads[which]) * dirs[:, which])
def radius(which, *ps):
return 2 * np.sqrt(directions(ps)[0][which])
def angle(*ps):
"""The angle of the first direction, in degrees."""
dirs = directions(ps)[1]
return np.degrees(np.arctan2(dirs[1, 0], dirs[0, 0]))
def projection(p, *ps):
"""Where point p lands on the line of the first direction."""
m = np.mean(ps, axis=0)
d = directions(ps)[1][:, 0]
return tuple(m + np.dot(np.array(p) - m, d) * d)
def describe(*ps):
spreads = directions(ps)[0]
return f'first direction: {spreads[0] / spreads.sum():.0%} of the spread'
end = pie.lift(axis_end, pie.Point)
size = pie.lift(radius, pie.Scalar)
onto = pie.lift(projection, pie.Point)
m = pie.lift(average, pie.Point)(*pts, hidden=True)
pie.ellipse(m, size(0, *pts), size(1, *pts), pie.lift(angle, pie.Scalar)(*pts), fill='rgba(13, 148, 136, 0.1)', stroke='none')
for p in pts:
pie.line(p, onto(p, *pts, hidden=True), stroke='#a8a29e')
pie.line(end(0, -1, *pts, hidden=True), end(0, 1, *pts, hidden=True), stroke='teal')
pie.line(end(1, -1, *pts, hidden=True), end(1, 1, *pts, hidden=True), stroke='#d97706')
pie.text(pie.lift(describe, str)(*pts), 0, 4.8, 14, stroke='#1c1917')
pie.text('Drag a point to turn the directions', 0, -4.8, 14, stroke='#1c1917')
The teal line makes the gray lines as short as possible. Unlike Line fit, the gaps are at right angles to the line, not vertical.