Lecture 02: KNN, ML Vocabulary, and K-Means – CS 189, Fall 2026
In this notebook we build our way from raw data to our first machine learning model, and then to our
first unsupervised model. We start with the tools we need to look at data (pandas, numpy, and
plotting), then introduce the simplest model we can think of, k-nearest neighbors. Along the way
KNN will force us to invent the ideas of generalization, the train/test split, and
hyperparameters. We close with k-means, which solves a related problem without any labels
at all.
The main body of this notebook is what we walk through in lecture. The Appendix at the end goes
much deeper on pandas, numpy, and visualization syntax; work through it after lecture.
# Download data & Install Dependencies
import os
import requests
os.makedirs("data", exist_ok=True)
data_files = {
"data/penguins.csv":
"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv",
}
for path, url in data_files.items():
if not os.path.exists(path):
r = requests.get(url)
r.raise_for_status()
with open(path, "wb") as f:
f.write(r.content)
print(f"Downloaded {path}")
else:
print(f"Found {path}")
Found data/penguins.csv
/Users/nargesnorouzi/Library/Python/3.9/lib/python/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020 warnings.warn(
import numpy as np
import pandas as pd
import plotly.express as px
px.defaults.width = 800
pd.set_option("plotting.backend", "plotly")
os.makedirs("images", exist_ok=True)
Part 1: Looking at the Data
Before any modeling, look at the data. This section is a fast tour of the tools; the Appendix has the full treatment of every function used here.
The Palmer Penguins Dataset
Measurements of 344 penguins from three islands in the Palmer Archipelago, Antarctica. Each row is one penguin. We will use this single dataset for everything today.
penguins = pd.read_csv("data/penguins.csv")
penguins.head()
| species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | |
|---|---|---|---|---|---|---|---|
| 0 | Adelie | Torgersen | 39.1 | 18.7 | 181.0 | 3750.0 | MALE |
| 1 | Adelie | Torgersen | 39.5 | 17.4 | 186.0 | 3800.0 | FEMALE |
| 2 | Adelie | Torgersen | 40.3 | 18.0 | 195.0 | 3250.0 | FEMALE |
| 3 | Adelie | Torgersen | NaN | NaN | NaN | NaN | NaN |
| 4 | Adelie | Torgersen | 36.7 | 19.3 | 193.0 | 3450.0 | FEMALE |
penguins.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 344 entries, 0 to 343 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 species 344 non-null object 1 island 344 non-null object 2 bill_length_mm 342 non-null float64 3 bill_depth_mm 342 non-null float64 4 flipper_length_mm 342 non-null float64 5 body_mass_g 342 non-null float64 6 sex 333 non-null object dtypes: float64(4), object(3) memory usage: 18.9+ KB
# describe() summarizes the numeric columns
penguins.describe()
| bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | |
|---|---|---|---|---|
| count | 342.000000 | 342.000000 | 342.000000 | 342.000000 |
| mean | 43.921930 | 17.151170 | 200.915205 | 4201.754386 |
| std | 5.459584 | 1.974793 | 14.061714 | 801.954536 |
| min | 32.100000 | 13.100000 | 172.000000 | 2700.000000 |
| 25% | 39.225000 | 15.600000 | 190.000000 | 3550.000000 |
| 50% | 44.450000 | 17.300000 | 197.000000 | 4050.000000 |
| 75% | 48.500000 | 18.700000 | 213.000000 | 4750.000000 |
| max | 59.600000 | 21.500000 | 231.000000 | 6300.000000 |
How much data do we have, and what are we looking at?
shapegives (rows, columns)value_counts()counts the occurrences of each value in a column
print("shape:", penguins.shape)
shape: (344, 7)
penguins["species"].value_counts()
species Adelie 152 Gentoo 124 Chinstrap 68 Name: count, dtype: int64
Selecting Data: `loc` and `iloc`
Two ways to pull a subset out of a DataFrame. The distinction matters constantly, so it is worth
getting straight now.
iloc[]selects by integer position, like indexing a numpy array. The end of a slice is excluded.loc[]selects by label, meaning the index value and the column name. The end of a slice is included.
# iloc: by position. Rows 0-4, first three columns.
penguins.iloc[0:5, 0:3]
| species | island | bill_length_mm | |
|---|---|---|---|
| 0 | Adelie | Torgersen | 39.1 |
| 1 | Adelie | Torgersen | 39.5 |
| 2 | Adelie | Torgersen | 40.3 |
| 3 | Adelie | Torgersen | NaN |
| 4 | Adelie | Torgersen | 36.7 |
# loc: by label. Rows 0-4 (inclusive!), named columns.
penguins.loc[0:4, ["species", "island", "bill_length_mm"]]
| species | island | bill_length_mm | |
|---|---|---|---|
| 0 | Adelie | Torgersen | 39.1 |
| 1 | Adelie | Torgersen | 39.5 |
| 2 | Adelie | Torgersen | 40.3 |
| 3 | Adelie | Torgersen | NaN |
| 4 | Adelie | Torgersen | 36.7 |
# A single column is a Series; a list of columns is a DataFrame
print(type(penguins["bill_length_mm"]))
print(type(penguins[["bill_length_mm"]]))
<class 'pandas.core.series.Series'> <class 'pandas.core.frame.DataFrame'>
Filtering and Missing Values
Real data has missing information. isna() finds them and dropna() removes the rows with missing NULL values.
penguins.isna().sum()
species 0 island 0 bill_length_mm 2 bill_depth_mm 2 flipper_length_mm 2 body_mass_g 2 sex 11 dtype: int64
# Boolean filtering: which penguins are both heavy and long-flippered?
mask = (penguins["body_mass_g"] > 5000) & (penguins["flipper_length_mm"] > 220)
penguins[mask].head()
| species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | |
|---|---|---|---|---|---|---|---|
| 221 | Gentoo | Biscoe | 50.0 | 16.3 | 230.0 | 5700.0 | MALE |
| 237 | Gentoo | Biscoe | 49.2 | 15.2 | 221.0 | 6300.0 | MALE |
| 239 | Gentoo | Biscoe | 48.7 | 15.1 | 222.0 | 5350.0 | MALE |
| 250 | Gentoo | Biscoe | 47.3 | 15.3 | 222.0 | 5250.0 | MALE |
| 253 | Gentoo | Biscoe | 59.6 | 17.0 | 230.0 | 6050.0 | MALE |
df = penguins.dropna()
print(len(penguins), "rows ->", len(df), "rows after dropping missing values")
df.head()
344 rows -> 333 rows after dropping missing values
| species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | |
|---|---|---|---|---|---|---|---|
| 0 | Adelie | Torgersen | 39.1 | 18.7 | 181.0 | 3750.0 | MALE |
| 1 | Adelie | Torgersen | 39.5 | 17.4 | 186.0 | 3800.0 | FEMALE |
| 2 | Adelie | Torgersen | 40.3 | 18.0 | 195.0 | 3250.0 | FEMALE |
| 4 | Adelie | Torgersen | 36.7 | 19.3 | 193.0 | 3450.0 | FEMALE |
| 5 | Adelie | Torgersen | 39.3 | 20.6 | 190.0 | 3650.0 | MALE |
Grouping and Summarizing
groupby() splits the data into groups, applies a function to each, and combines the results.
df.groupby("species")[["bill_length_mm", "flipper_length_mm", "body_mass_g"]].mean().round(1)
| bill_length_mm | flipper_length_mm | body_mass_g | |
|---|---|---|---|
| species | |||
| Adelie | 38.8 | 190.1 | 3706.2 |
| Chinstrap | 48.8 | 195.8 | 3733.1 |
| Gentoo | 47.6 | 217.2 | 5092.4 |
`numpy`: the Array Underneath
pandas is built on numpy. Models in scikit-learn want numpy arrays, so this is how we hand our
data over.
FEATURES = ["bill_length_mm", "flipper_length_mm"]
X = df[FEATURES].to_numpy() # feature matrix, shape (n_samples, n_features)
y = df["species"].to_numpy() # labels, shape (n_samples,)
print("X shape:", X.shape, "| y shape:", y.shape)
print("X dtype:", X.dtype, "\n")
print("The first three rows of X:\n", X[:3])
X shape: (333, 2) | y shape: (333,) X dtype: float64 The first three rows of X: [[ 39.1 181. ] [ 39.5 186. ] [ 40.3 195. ]]
# Vectorized operations act on the whole array at once, with no Python loop.
print("column means:", X.mean(axis=0).round(2))
print("column stds: ", X.std(axis=0).round(2))
column means: [ 43.99 200.97] column stds: [ 5.46 13.99]
Visualizing: Can We See the Species?
If the species separate visually in these two dimensions, then a model has a chance.
fig = px.scatter(
df, x="bill_length_mm", y="flipper_length_mm", color="species",
title="Palmer Penguins: bill length vs flipper length",
labels={
"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)",
"species": "Species",
},
height=520,
)
# Plotly's default legend sits off to the right and often gets clipped in notebooks.
fig.update_layout(
showlegend=True,
legend=dict(
title_text="Species",
x=0.01,
y=0.99,
xanchor="left",
yanchor="top",
bgcolor="white",
bordercolor="lightgray",
borderwidth=1,
),
)
fig.show()
Look at what this plot is telling us. The three species occupy different regions. Nothing separates them perfectly, but points near each other tend to share a species.
Part 2: The Simplest Possible Model
We want to predict a penguin's species from its bill length and flipper length. We ask ourselves, what is the least clever thing that could possibly work?
K-Nearest Neighbors (KNN) with k = 1
To predict the species of a new penguin, find the penguin in our data that is closest to it, and copy that penguin's species.
That is the entire algorithm. Notice what it does not have any optimization. "Training" is nothing more than storing the data.
from sklearn.neighbors import KNeighborsClassifier
knn1 = KNeighborsClassifier(n_neighbors=1)
knn1.fit(X, y) # "training": store the data
print("Model is trained.")
Model is trained.
# Inference: predict the species of a new penguin
new_penguin = np.array([[45.0, 200.0]]) # bill 45mm, flipper 200mm
print("Predicted species:", knn1.predict(new_penguin)[0])
Predicted species: Chinstrap
How Good Is It?
We ask the model to predict the species of every penguin in our dataset, and count how often it is right.
accuracy = knn1.score(X, y)
print(f"Accuracy on our data: {accuracy:.1%}")
Accuracy on our data: 100.0%
# Why 100%? Ask which point is nearest to the first penguin in our data.
distances, indices = knn1.kneighbors(X[:1], n_neighbors=1)
print("Nearest neighbour of penguin 0 is penguin index:", indices[0][0])
print("at a distance of:", distances[0][0])
Nearest neighbour of penguin 0 is penguin index: 0 at a distance of: 0.0
The nearest neighbour of every penguin in our dataset is itself, at distance zero. We asked the model to answer questions it had already memorized the answers to.
This tells us nothing about whether the model has learned anything. We need to evaluate it on penguins it has never seen.
Part 3: Generalization and the Train/Test Split
Generalization is the ability to perform well on new, unseen data drawn from the same distribution. To measure it, we hold data back.
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} penguins")
print(f"Test set: {X_test.shape[0]} penguins")
Training set: 266 penguins Test set: 67 penguins
knn1 = KNeighborsClassifier(n_neighbors=1).fit(X_train, y_train)
print(f"Training accuracy: {knn1.score(X_train, y_train):.1%}")
print(f"Test accuracy: {knn1.score(X_test, y_test):.1%}")
Training accuracy: 100.0% Test accuracy: 95.5%
Training accuracy is still 100%, and it always will be for $k=1$, because every training point is still its own nearest neighbour. The test accuracy is the honest number, and it is meaningfully lower.
The gap between those two numbers is the thing we spend the rest of the semester managing.
Part 4: k Is a Choice
Why look at only one neighbour? Let each of the $k$ nearest neighbours vote, and take the majority.
Seeing k in the Decision Boundary
A decision boundary shows what the model would predict at every point in the feature space. It makes the effect of $k$ visible.
import plotly.graph_objects as go
from sklearn.preprocessing import LabelEncoder
def decision_boundary_figure(k, X_tr, y_tr, resolution=250):
le = LabelEncoder().fit(y_tr)
model = KNeighborsClassifier(n_neighbors=k).fit(X_tr, le.transform(y_tr))
pad = 1.0
xs = np.linspace(X_tr[:, 0].min() - pad, X_tr[:, 0].max() + pad, resolution)
ys = np.linspace(X_tr[:, 1].min() - pad, X_tr[:, 1].max() + pad, resolution)
xx, yy = np.meshgrid(xs, ys)
zz = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
fig = go.Figure()
fig.add_trace(go.Heatmap(x=xs, y=ys, z=zz, showscale=False, opacity=0.30,
colorscale=[[0, "#002675"], [0.5, "#FDB515"], [1, "#028842"]]))
for i, cls in enumerate(le.classes_):
m = y_tr == cls
fig.add_trace(go.Scatter(
x=X_tr[m, 0], y=X_tr[m, 1], mode="markers", name=cls,
marker=dict(size=7, line=dict(width=1, color="white"),
color=["#002675", "#FDB515", "#028842"][i])))
fig.update_layout(title=f"KNN decision boundary, k = {k}",
xaxis_title="Bill length (mm)", yaxis_title="Flipper length (mm)",
width=800, height=520)
return fig
decision_boundary_figure(1, X_train, y_train).show()
k = 1 gives a noisy boundary with little islands around individual points. The model is contorting itself to get every single training penguin right, including the ones that are unusual. That is overfitting: fitting the noise as well as the signal.
decision_boundary_figure(15, X_train, y_train).show()
decision_boundary_figure(100, X_train, y_train).show()
k = 15 smooths the boundary into something that looks like a real trend.
k = 100 smooths it so much that the model is barely paying attention to the data. That is underfitting.
So $k$ controls a trade-off, and somewhere in between is a good value.
Finding a Good k
We cannot use the test set to pick $k$. The moment we tune anything against the test set, it stops measuring generalization and becomes just another training set.
So we split again: a validation set, carved out of the training data.
X_tr, X_val, y_tr, y_val = train_test_split(
X_train, y_train, test_size=0.25, random_state=42, stratify=y_train
)
print(f"train {len(X_tr)} | validation {len(X_val)} | test {len(X_test)}")
train 199 | validation 67 | test 67
ks = list(range(1, 101))
train_acc, val_acc = [], []
for k in ks:
m = KNeighborsClassifier(n_neighbors=k).fit(X_tr, y_tr)
train_acc.append(m.score(X_tr, y_tr))
val_acc.append(m.score(X_val, y_val))
best_k = ks[int(np.argmax(val_acc))]
print(f"Best k on the validation set: {best_k} (validation accuracy {max(val_acc):.1%})")
Best k on the validation set: 3 (validation accuracy 95.5%)
fig = go.Figure()
fig.add_trace(go.Scatter(x=ks, y=train_acc, name="Training accuracy",
line=dict(color="#002675", width=3)))
fig.add_trace(go.Scatter(x=ks, y=val_acc, name="Validation accuracy",
line=dict(color="#FDB515", width=3)))
fig.add_vline(x=best_k, line_dash="dash", line_color="#028842",
annotation_text=f"best k = {best_k}")
fig.update_layout(title="Training and validation accuracy as k increases",
xaxis_title="k (number of neighbours)", yaxis_title="Accuracy",
width=800, height=520)
fig.show()
Read this plot carefully, because its shape recurs all semester.
- On the left (small $k$) training accuracy is perfect and validation accuracy is lower. Overfitting.
- On the right (large $k$) both accuracies fall together. Underfitting.
- The sweet spot is where validation accuracy peaks.
$k$ is a hyperparameter: a value we choose before training, as opposed to a parameter, which is learned from the data during training. KNN is unusual in having no parameters at all, only this one hyperparameter. That makes it a clean place to see the distinction.
# Only now, having chosen k, do we touch the test set. Once.
final = KNeighborsClassifier(n_neighbors=best_k).fit(X_train, y_train)
print(f"Final test accuracy with k={best_k}: {final.score(X_test, y_test):.1%}")
Final test accuracy with k=3: 97.0%
A Wrinkle: Distance Depends on Units
KNN is built entirely on distance, so it cares about the scale of the features. Flipper length spans roughly 172-231 mm and bill length roughly 32-60 mm, so flipper length contributes far more to the distance simply because its numbers are bigger.
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
scaled = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k))
scaled.fit(X_train, y_train)
print(f"Unscaled test accuracy: {final.score(X_test, y_test):.1%}")
print(f"Scaled test accuracy: {scaled.score(X_test, y_test):.1%}")
Unscaled test accuracy: 97.0% Scaled test accuracy: 97.0%
Standardization rescales each feature to have mean 0 and standard deviation 1, so every feature contributes to the distance on equal terms. For any distance-based model this is not optional.
Part 5: The Same Idea for Regression
So far we predicted a category (species). What if we want to predict a number, like body mass?
The algorithm barely changes. Find the k nearest neighbours, and instead of taking a majority vote, take their average.
from sklearn.neighbors import KNeighborsRegressor
Xr = df[["flipper_length_mm"]].to_numpy()
yr = df["body_mass_g"].to_numpy()
Xr_train, Xr_test, yr_train, yr_test = train_test_split(Xr, yr, test_size=0.2, random_state=42)
print("Predicting body mass (g) from flipper length (mm)")
Predicting body mass (g) from flipper length (mm)
grid = np.linspace(Xr.min(), Xr.max(), 400).reshape(-1, 1)
fig = go.Figure()
fig.add_trace(go.Scatter(x=Xr_train.ravel(), y=yr_train, mode="markers", name="Training data",
marker=dict(color="lightgray", size=6)))
for k, colour in [(1, "#002675"), (25, "#FDB515"), (200, "#028842")]:
reg = KNeighborsRegressor(n_neighbors=k).fit(Xr_train, yr_train)
fig.add_trace(go.Scatter(x=grid.ravel(), y=reg.predict(grid), mode="lines",
name=f"k = {k}", line=dict(width=3, color=colour)))
fig.update_layout(title="KNN regression: body mass vs flipper length",
xaxis_title="Flipper length (mm)", yaxis_title="Body mass (g)",
width=800, height=520)
fig.show()
The same trade-off, in a different costume. $k=1$ is a jagged step function chasing every point. $k=200$ is nearly a flat line. The middle is where the real trend lives.
Overfitting and underfitting are not facts about classification. They are facts about model complexity.
for k in [1, 25, 200]:
reg = KNeighborsRegressor(n_neighbors=k).fit(Xr_train, yr_train)
print(f"k={k:>3} train R^2 = {reg.score(Xr_train, yr_train):.3f} "
f"test R^2 = {reg.score(Xr_test, yr_test):.3f}")
k= 1 train R^2 = 0.646 test R^2 = 0.491 k= 25 train R^2 = 0.785 test R^2 = 0.802 k=200 train R^2 = 0.422 test R^2 = 0.427
At k=1 the training score is far above the test score: the model is memorizing. At k=200 the two scores collapse together, and both are bad: the model is too simple to capture the trend. The same story the classification curve told, told again.
Part 6: What If We Had No Labels?
Everything so far was supervised: every penguin came with its species attached.
Now suppose a field researcher hands us the same measurements with no species labels at all, and asks: are there natural groups in here?
This is unsupervised learning, and specifically clustering.
# Throw the labels away.
fig = px.scatter(df, x="bill_length_mm", y="flipper_length_mm",
title="The same penguins, with no labels",
labels={"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)"},
height=520)
fig.update_traces(marker=dict(color="gray", size=7))
fig.show()
K-Means in scikit-learn
K-means partitions the data into K clusters, each represented by a centroid. It assigns each point to the nearest centroid, then moves each centroid to the mean of its points, and repeats.
from sklearn.cluster import KMeans
Xs = StandardScaler().fit_transform(X) # distance-based again, so standardize
kmeans = KMeans(n_clusters=3, n_init=10, random_state=42)
cluster = kmeans.fit_predict(Xs)
df_c = df.copy()
df_c["cluster"] = cluster.astype(str)
px.scatter(df_c, x="bill_length_mm", y="flipper_length_mm", color="cluster",
title="K-means with K = 3",
labels={"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)"},
height=520).show()
Lloyd's Algorithm, Step by Step
Watch the centroids move. Each iteration is two steps: assign, then update.
def lloyd_steps(Xs, K=3, n_iter=6, seed=1):
rng = np.random.default_rng(seed)
centres = Xs[rng.choice(len(Xs), K, replace=False)].copy()
history = []
for _ in range(n_iter):
d = ((Xs[:, None, :] - centres[None, :, :]) ** 2).sum(axis=2)
assign = d.argmin(axis=1) # assignment step
history.append((centres.copy(), assign.copy()))
for j in range(K): # update step
if (assign == j).any():
centres[j] = Xs[assign == j].mean(axis=0)
return history
history = lloyd_steps(Xs)
print(f"{len(history)} iterations recorded")
6 iterations recorded
palette = ["#002675", "#FDB515", "#028842"]
fig = go.Figure()
frames = []
for step, (centres, assign) in enumerate(history):
data = []
for j in range(3):
m = assign == j
data.append(go.Scatter(x=Xs[m, 0], y=Xs[m, 1], mode="markers",
marker=dict(color=palette[j], size=6), name=f"cluster {j}"))
data.append(go.Scatter(x=centres[:, 0], y=centres[:, 1], mode="markers",
marker=dict(color="black", size=18, symbol="x"), name="centroids"))
frames.append(go.Frame(data=data, name=str(step)))
fig.add_traces(frames[0].data)
fig.frames = frames
fig.update_layout(
title="Lloyd's algorithm: assign, then update",
xaxis_title="Bill length (standardized)", yaxis_title="Flipper length (standardized)",
width=800, height=560,
updatemenus=[dict(type="buttons", showactive=False,
buttons=[dict(label="Play", method="animate",
args=[None, dict(frame=dict(duration=900, redraw=True),
fromcurrent=True)])])])
fig.show()
Choosing K
K is a hyperparameter, exactly like k in KNN. But there is no validation accuracy to optimize, because there are no labels. The common heuristic is the elbow method: plot the within-cluster sum of squares (inertia) against K and look for the bend.
Ks = range(1, 11)
inertia = [KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs).inertia_ for k in Ks]
fig = px.line(x=list(Ks), y=inertia, markers=True,
title="Elbow method: inertia vs K",
labels={"x": "K (number of clusters)", "y": "Within-cluster sum of squares"})
fig.update_traces(line=dict(color="#002675", width=3))
fig.update_layout(width=800, height=480)
fig.show()
The bend is around K=3, which is encouraging. But note how much judgment that reading takes. The elbow is a heuristic, not a criterion.
Part 7: Clusters Are Not Labels
We have run two models on the same two columns. One was told the species; one was not. How much did the labels actually buy us?
comparison = pd.crosstab(df_c["cluster"], df_c["species"])
comparison
| species | Adelie | Chinstrap | Gentoo |
|---|---|---|---|
| cluster | |||
| 0 | 141 | 5 | 0 |
| 1 | 1 | 4 | 118 |
| 2 | 4 | 59 | 1 |
fig = px.scatter(df_c, x="bill_length_mm", y="flipper_length_mm",
color="species", symbol="cluster",
title="True species (colour) vs discovered clusters (symbol)",
labels={"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)"},
height=560)
fig.show()
K-means, with no access to the labels at all, largely rediscovered the species.
But be careful about what that means. The clusters are not species. K-means found groups of penguins that are close together in bill and flipper measurements, and in this dataset those groups happen to line up with species. Change the features and the clusters change. Nothing in the algorithm knows what a species is, and nothing guarantees the groups it finds correspond to anything you care about.
Appendix: Going Deeper
The material below was not covered in lecture. Work through it on your own; it is the reference for the syntax used above and for Homework 1.
A1. `pandas` Data Structures
A DataFrame is a 2-dimensional table. A Series is a single column. Both are built on an Index.
s = pd.Series([3750, 3800, 3250], index=["p0", "p1", "p2"], name="body_mass_g")
print(s)
print("\nindex:", s.index.tolist())
print("values:", s.values)
p0 3750 p1 3800 p2 3250 Name: body_mass_g, dtype: int64 index: ['p0', 'p1', 'p2'] values: [3750 3800 3250]
frame = pd.DataFrame({
"species": ["Adelie", "Gentoo", "Chinstrap"],
"bill_length_mm": [39.1, 46.1, 46.5],
"island": ["Torgersen", "Biscoe", "Dream"],
})
frame
| species | bill_length_mm | island | |
|---|---|---|---|
| 0 | Adelie | 39.1 | Torgersen |
| 1 | Gentoo | 46.1 | Biscoe |
| 2 | Chinstrap | 46.5 | Dream |
A2. Exploring a `DataFrame`
print(penguins.head(3)) # first rows
print(penguins.tail(3)) # last rows
print(penguins.sample(3)) # random rows
print(penguins.columns.tolist()) # column names
print(penguins.dtypes) # column types
print(penguins["island"].unique())
species island bill_length_mm bill_depth_mm flipper_length_mm \
0 Adelie Torgersen 39.1 18.7 181.0
1 Adelie Torgersen 39.5 17.4 186.0
2 Adelie Torgersen 40.3 18.0 195.0
body_mass_g sex
0 3750.0 MALE
1 3800.0 FEMALE
2 3250.0 FEMALE
species island bill_length_mm bill_depth_mm flipper_length_mm \
341 Gentoo Biscoe 50.4 15.7 222.0
342 Gentoo Biscoe 45.2 14.8 212.0
343 Gentoo Biscoe 49.9 16.1 213.0
body_mass_g sex
341 5750.0 MALE
342 5200.0 FEMALE
343 5400.0 MALE
species island bill_length_mm bill_depth_mm flipper_length_mm \
287 Gentoo Biscoe 49.5 16.2 229.0
14 Adelie Torgersen 34.6 21.1 198.0
142 Adelie Dream 32.1 15.5 188.0
body_mass_g sex
287 5800.0 MALE
14 4400.0 MALE
142 3050.0 FEMALE
['species', 'island', 'bill_length_mm', 'bill_depth_mm', 'flipper_length_mm', 'body_mass_g', 'sex']
species object
island object
bill_length_mm float64
bill_depth_mm float64
flipper_length_mm float64
body_mass_g float64
sex object
dtype: object
['Torgersen' 'Biscoe' 'Dream']
A3. `loc` and `iloc` in Full
The rule to remember: iloc is positional and excludes the endpoint, loc is label-based and
includes it.
print(penguins.iloc[0]) # row 0 as a Series
print(penguins.iloc[0:3]) # rows 0,1,2
print(penguins.iloc[:, 2]) # column at position 2
print(penguins.iloc[[0, 5, 10], [0, 2]]) # arbitrary rows and columns
species Adelie
island Torgersen
bill_length_mm 39.1
bill_depth_mm 18.7
flipper_length_mm 181.0
body_mass_g 3750.0
sex MALE
Name: 0, dtype: object
species island bill_length_mm bill_depth_mm flipper_length_mm \
0 Adelie Torgersen 39.1 18.7 181.0
1 Adelie Torgersen 39.5 17.4 186.0
2 Adelie Torgersen 40.3 18.0 195.0
body_mass_g sex
0 3750.0 MALE
1 3800.0 FEMALE
2 3250.0 FEMALE
0 39.1
1 39.5
2 40.3
3 NaN
4 36.7
...
339 NaN
340 46.8
341 50.4
342 45.2
343 49.9
Name: bill_length_mm, Length: 344, dtype: float64
species bill_length_mm
0 Adelie 39.1
5 Adelie 39.3
10 Adelie 37.8
print(penguins.loc[0:2]) # rows labelled 0,1,2 (inclusive)
print(penguins.loc[:, "species":"bill_length_mm"]) # column slice by name
print(penguins.loc[penguins["species"] == "Gentoo", "body_mass_g"].mean())
species island bill_length_mm bill_depth_mm flipper_length_mm \
0 Adelie Torgersen 39.1 18.7 181.0
1 Adelie Torgersen 39.5 17.4 186.0
2 Adelie Torgersen 40.3 18.0 195.0
body_mass_g sex
0 3750.0 MALE
1 3800.0 FEMALE
2 3250.0 FEMALE
species island bill_length_mm
0 Adelie Torgersen 39.1
1 Adelie Torgersen 39.5
2 Adelie Torgersen 40.3
3 Adelie Torgersen NaN
4 Adelie Torgersen 36.7
.. ... ... ...
339 Gentoo Biscoe NaN
340 Gentoo Biscoe 46.8
341 Gentoo Biscoe 50.4
342 Gentoo Biscoe 45.2
343 Gentoo Biscoe 49.9
[344 rows x 3 columns]
5076.016260162602
A4. Modifying and Sorting
tmp = df.copy()
tmp["mass_kg"] = tmp["body_mass_g"] / 1000 # add a column
tmp["bill_ratio"] = tmp["bill_length_mm"] / tmp["bill_depth_mm"]
tmp = tmp.drop(columns=["bill_ratio"]) # drop a column
tmp.sort_values("body_mass_g", ascending=False).head()
| species | island | bill_length_mm | bill_depth_mm | flipper_length_mm | body_mass_g | sex | mass_kg | |
|---|---|---|---|---|---|---|---|---|
| 237 | Gentoo | Biscoe | 49.2 | 15.2 | 221.0 | 6300.0 | MALE | 6.30 |
| 253 | Gentoo | Biscoe | 59.6 | 17.0 | 230.0 | 6050.0 | MALE | 6.05 |
| 297 | Gentoo | Biscoe | 51.1 | 16.3 | 220.0 | 6000.0 | MALE | 6.00 |
| 337 | Gentoo | Biscoe | 48.8 | 16.2 | 222.0 | 6000.0 | MALE | 6.00 |
| 299 | Gentoo | Biscoe | 45.2 | 16.4 | 223.0 | 5950.0 | MALE | 5.95 |
A5. Aggregation, `groupby`, and Pivot Tables
print(df.groupby("species")["body_mass_g"].agg(["mean", "std", "count"]).round(1))
print()
print(df.groupby(["species", "island"])["flipper_length_mm"].mean().round(1))
print()
print(df.pivot_table(index="species", columns="island",
values="body_mass_g", aggfunc="mean").round(0))
mean std count
species
Adelie 3706.2 458.6 146
Chinstrap 3733.1 384.3 68
Gentoo 5092.4 501.5 119
species island
Adelie Biscoe 188.8
Dream 189.9
Torgersen 191.5
Chinstrap Dream 195.8
Gentoo Biscoe 217.2
Name: flipper_length_mm, dtype: float64
island Biscoe Dream Torgersen
species
Adelie 3710.0 3701.0 3709.0
Chinstrap NaN 3733.0 NaN
Gentoo 5092.0 NaN NaN
A6. Joining `DataFrames`
islands = pd.DataFrame({
"island": ["Torgersen", "Biscoe", "Dream"],
"latitude": [-64.77, -65.43, -64.73],
})
merged = df.merge(islands, on="island", how="left") # try how="inner" and how="outer"
merged[["species", "island", "latitude"]].head()
| species | island | latitude | |
|---|---|---|---|
| 0 | Adelie | Torgersen | -64.77 |
| 1 | Adelie | Torgersen | -64.77 |
| 2 | Adelie | Torgersen | -64.77 |
| 3 | Adelie | Torgersen | -64.77 |
| 4 | Adelie | Torgersen | -64.77 |
A7. `numpy` Essentials
a = np.arange(12).reshape(3, 4)
print(a)
print("shape:", a.shape, "| ndim:", a.ndim)
print("row sums:", a.sum(axis=1))
print("col means:", a.mean(axis=0))
print("boolean mask:", a[a > 6])
print("broadcasting:", (a - a.mean(axis=0)).round(2))
[[ 0 1 2 3] [ 4 5 6 7] [ 8 9 10 11]] shape: (3, 4) | ndim: 2 row sums: [ 6 22 38] col means: [4. 5. 6. 7.] boolean mask: [ 7 8 9 10 11] broadcasting: [[-4. -4. -4. -4.] [ 0. 0. 0. 0.] [ 4. 4. 4. 4.]]
# Euclidean distance by hand, which is exactly what KNN computes
p, q = X[0], X[1]
print("manual :", np.sqrt(((p - q) ** 2).sum()))
print("numpy :", np.linalg.norm(p - q))
manual : 5.015974481593781 numpy : 5.015974481593781
A8. Visualization Reference
matplotlib and seaborn are the static plotting standards; plotly gives interactive figures,
which is why we use it in lecture.
import matplotlib.pyplot as plt
import seaborn as sns
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].hist(df["body_mass_g"], bins=25, color="#002675")
axes[0].set_title("Body mass"); axes[0].set_xlabel("g")
sns.scatterplot(data=df, x="bill_length_mm", y="flipper_length_mm",
hue="species", ax=axes[1])
axes[1].set_title("By species")
plt.tight_layout(); plt.show()
# Plotly: histogram, box plot, and a faceted scatter
px.histogram(df, x="body_mass_g", color="species", nbins=30, height=420).show()
px.box(df, x="species", y="flipper_length_mm", color="species", height=420).show()
px.scatter(df, x="bill_length_mm", y="flipper_length_mm",
color="species", facet_col="island", height=420).show()