Drag the slope slider below. For each slope you choose, the intercept is set to the value that best fits that slope, so the vermillion line always pivots through the green centroid (xˉ,yˉ). The green vertical segments are the residuals -- the gaps between each point and your line -- and the title reports the sum of squared errors SSE=∑(yi−y^i)2. As you drag, watch the SSE shrink to its smallest possible value exactly at the least-squares slope (the grey dotted target line), then grow again. That single lowest point is what “least squares” means.
import numpy as np
import plotly.graph_objects as go
from IPython.display import HTML
# Okabe-Ito colorblind-safe palette. Each element is also given a distinct
# marker SHAPE or line STYLE so the figure never relies on color alone.
OI_BLUE = "#0072B2" # the data points (circles)
OI_VERM = "#D55E00" # the draggable candidate line (thick solid)
OI_GREEN = "#009E73" # residual segments + the pivot point (diamond)
OI_GREY = "#999999" # the least-squares target line (thin dotted)
# A SMALL simulated scatter with a genuine positive linear trend plus noise.
# Fixed seed -> identical every build. These are abstract teaching units, NOT a
# real Kern County measurement; no real-world statistic is claimed here.
rng = np.random.default_rng(2200)
n = 12
x = np.linspace(2.0, 24.0, n) # evenly spaced predictor
y = 5.0 + 1.4 * x + rng.normal(0.0, 4.5, n) # true trend + Gaussian noise
# Centroid and the least-squares building blocks (Chapter 13 formulas).
xbar, ybar = x.mean(), y.mean()
Sxx = np.sum((x - xbar) ** 2)
Sxy = np.sum((x - xbar) * (y - ybar))
b1_ls = Sxy / Sxx # the SSE-minimizing slope
b0_ls = ybar - b1_ls * xbar # its matching intercept
def sse_of(slope):
"""SSE when the intercept is the least-squares value for this slope."""
b0 = ybar - slope * xbar # intercept that best fits THIS slope
yhat = b0 + slope * x
return float(np.sum((y - yhat) ** 2))
sse_min = sse_of(b1_ls) # the smallest SSE achievable
# Slope slider stops, centred so the MIDDLE stop is exactly the LS slope.
slopes = [b1_ls + off for off in np.linspace(-0.9, 0.9, 13)]
# Plot bounds wide enough to hold the steepest candidate line at every stop.
all_y = list(y)
for s in slopes:
b0 = ybar - s * xbar
all_y += [b0 + s * x.min(), b0 + s * x.max()]
ypad = 0.10 * (max(all_y) - min(all_y))
y_range = [min(all_y) - ypad, max(all_y) + ypad]
x_range = [0.0, x.max() + 2.0]
fig = go.Figure()
# --- Always-visible reference traces (indices 0,1,2) ---------------------
# 0: the data points (blue circles).
fig.add_trace(go.Scatter(
x=x, y=y, mode="markers", name="simulated data",
marker=dict(color=OI_BLUE, symbol="circle", size=10,
line=dict(color="white", width=1)),
hovertemplate="x = %{x:.1f}<br>y = %{y:.1f}<extra></extra>",
))
# 1: the least-squares target line (grey, DOTTED -> distinct by style).
fig.add_trace(go.Scatter(
x=[x.min(), x.max()],
y=[b0_ls + b1_ls * x.min(), b0_ls + b1_ls * x.max()],
mode="lines", name="least-squares line (target)",
line=dict(color=OI_GREY, width=1.5, dash="dot"),
hoverinfo="skip",
))
# 2: the pivot point (x-bar, y-bar) as a green DIAMOND (distinct by shape).
fig.add_trace(go.Scatter(
x=[xbar], y=[ybar], mode="markers", name="centroid (x-bar, y-bar)",
marker=dict(color=OI_GREEN, symbol="diamond", size=13,
line=dict(color="white", width=1)),
hovertemplate="centroid<br>x-bar = %{x:.1f}<br>y-bar = %{y:.1f}<extra></extra>",
))
# --- Per-slope traces: a candidate line + its residual segments ----------
# Two traces per slider stop; only the first stop's pair is visible at load.
for k, s in enumerate(slopes):
b0 = ybar - s * xbar
# Candidate regression line (thick vermillion solid).
fig.add_trace(go.Scatter(
x=[x.min(), x.max()],
y=[b0 + s * x.min(), b0 + s * x.max()],
mode="lines", name=f"candidate (b1={s:.2f})", visible=(k == 0),
line=dict(color=OI_VERM, width=3),
hovertemplate="candidate line<extra></extra>",
))
# Residual segments: vertical drops from each point to the candidate line,
# drawn as ONE trace using None separators between segments.
rx, ry = [], []
for xi, yi in zip(x, y):
rx += [xi, xi, None]
ry += [yi, b0 + s * xi, None]
fig.add_trace(go.Scatter(
x=rx, y=ry, mode="lines", name="residuals", visible=(k == 0),
line=dict(color=OI_GREEN, width=1.5), hoverinfo="skip",
showlegend=False,
))
n_fixed = 3 # the three always-visible reference traces
# Slider: reveal exactly one stop's (line + residual) pair, update the title.
steps = []
for k, s in enumerate(slopes):
vis = [True] * n_fixed
for j in range(len(slopes)):
vis += [j == k, j == k]
sse = sse_of(s)
steps.append(dict(
method="update", label=f"{s:.2f}",
args=[{"visible": vis},
{"title.text":
f"Candidate slope b1 = {s:.2f}, intercept b0 = "
f"{ybar - s * xbar:.2f} -> SSE = {sse:,.0f}"
f" (minimum possible SSE = {sse_min:,.0f} at b1 = {b1_ls:.2f})"}]))
fig.add_annotation(
xref="paper", yref="paper", x=0.02, y=0.98, showarrow=False, align="left",
bgcolor="rgba(255,255,255,0.75)", bordercolor=OI_GREY, borderwidth=1,
font=dict(size=12),
text=(f"Least-squares slope b1* = {b1_ls:.2f}<br>"
f"gives the smallest SSE = {sse_min:,.0f}.<br>"
"Every candidate line pivots<br>through the green centroid."))
fig.update_layout(
template="simple_white", showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0),
xaxis_title="x (simulated predictor)",
yaxis_title="y (simulated response)",
xaxis_range=x_range, yaxis_range=y_range, # FIXED so the view never jumps
height=520, margin=dict(t=110, r=20, b=70, l=70),
sliders=[dict(active=0, pad={"t": 50},
currentvalue={"prefix": "Slope b1 = "}, steps=steps)],
title=(f"Candidate slope b1 = {slopes[0]:.2f}, intercept b0 = "
f"{ybar - slopes[0] * xbar:.2f} -> SSE = {sse_of(slopes[0]):,.0f}"
f" (minimum possible SSE = {sse_min:,.0f} at b1 = {b1_ls:.2f})"),
)
# THE GOTCHA FIX: emit text/html ourselves (embeddable div + CDN plotly.js).
HTML(fig.to_html(full_html=False, include_plotlyjs="cdn"))
Loading...