Unsupervised Learning
Discover hidden structure in unlabeled data — dimensionality reduction with PCA/UMAP, anomaly detection, and topic modeling.
Real-World Scenario
An e-commerce company has 500,000 customer records with 50 behavioral features — no labels, no segments. They run K-Means to find 6 natural customer groups. PCA reduces the 50 features to 10 components (explaining 85% of variance) before clustering, dramatically improving cluster quality. UMAP provides a 2D visualization that confirms the clusters make business sense.
Principal Component Analysis (PCA)
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_digits
import numpy as np
# Digits dataset: 1797 samples × 64 features (8×8 pixel images)
X, y = load_digits(return_X_y=True)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Full PCA — find how many components explain 95% of variance
pca_full = PCA()
pca_full.fit(X_scaled)
# Cumulative explained variance
cumvar = np.cumsum(pca_full.explained_variance_ratio_)
n_95 = np.argmax(cumvar >= 0.95) + 1
n_99 = np.argmax(cumvar >= 0.99) + 1
print(f"Components for 95% variance: {n_95} (from 64 features)")
print(f"Components for 99% variance: {n_99}")
# Reduce dimensionality
pca = PCA(n_components=n_95, random_state=42)
X_reduced = pca.fit_transform(X_scaled)
print(f"Reduced shape: {X_reduced.shape}")
print(f"Variance explained: {pca.explained_variance_ratio_.sum():.3f}")
# PCA as preprocessing improves downstream classifiers
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
clf_raw = RandomForestClassifier(100, random_state=42, n_jobs=-1)
clf_pca = RandomForestClassifier(100, random_state=42, n_jobs=-1)
score_raw = cross_val_score(clf_raw, X_scaled, y, cv=5, scoring="accuracy").mean()
score_pca = cross_val_score(clf_pca, X_reduced, y, cv=5, scoring="accuracy").mean()
print(f"RF on 64 features: {score_raw:.4f}")
print(f"RF on {n_95} PCA components: {score_pca:.4f}")
UMAP for Visualization
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_digits
X, y = load_digits(return_X_y=True)
X_scaled = StandardScaler().fit_transform(X)
# UMAP: install with: pip install umap-learn
import umap
reducer = umap.UMAP(
n_components=2,
n_neighbors=15, # local vs global structure trade-off — higher = more global
min_dist=0.1, # how tightly points cluster — lower = tighter
metric="euclidean",
random_state=42,
)
X_2d = reducer.fit_transform(X_scaled)
print(f"UMAP output: {X_2d.shape}") # (1797, 2)
# Check separation quality: same-class points should be close
from sklearn.metrics import silhouette_score
sil = silhouette_score(X_2d, y)
print(f"Silhouette score in 2D UMAP space: {sil:.3f}")
# Quick text-based check of separation
for digit in range(10):
mask = y == digit
center = X_2d[mask].mean(axis=0)
print(f"Digit {digit}: center=({center[0]:.1f}, {center[1]:.1f})")
Anomaly Detection
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.preprocessing import StandardScaler
import numpy as np
rng = np.random.default_rng(42)
# Simulated dataset: server metrics with anomalies
n_normal = 1000
n_anomaly = 50
normal = rng.multivariate_normal([50, 70], [[100, 30], [30, 100]], n_normal)
anomaly = rng.uniform([0, 0], [200, 200], (n_anomaly, 2))
X = np.vstack([normal, anomaly])
y_true = np.array([1] * n_normal + [-1] * n_anomaly) # 1=normal, -1=anomaly
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Isolation Forest: randomly partitions data, anomalies need fewer splits
iso_forest = IsolationForest(
contamination=0.05, # expected fraction of anomalies
n_estimators=100,
random_state=42,
)
iso_pred = iso_forest.fit_predict(X_scaled) # 1=normal, -1=anomaly
# Anomaly scores — more negative = more anomalous
iso_scores = iso_forest.score_samples(X_scaled) # negative log-density
# Evaluate
from sklearn.metrics import classification_report, roc_auc_score
print("Isolation Forest:")
print(classification_report(y_true, iso_pred, target_names=["anomaly", "normal"]))
# LOF: density-based, compares point density to its neighbors
lof = LocalOutlierFactor(
n_neighbors=20,
contamination=0.05,
)
lof_pred = lof.fit_predict(X_scaled)
print("Local Outlier Factor:")
print(classification_report(y_true, lof_pred, target_names=["anomaly", "normal"]))
# Real-world usage: flag anomalies for human review
def flag_anomalies(X_new: np.ndarray, model, scaler, threshold: float = -0.5):
X_s = scaler.transform(X_new)
preds = model.predict(X_s)
return np.where(preds == -1)[0]
test_data = rng.uniform([0, 0], [200, 200], (5, 2)) # these should all be anomalies
flagged = flag_anomalies(test_data, iso_forest, scaler)
print(f"Anomalies flagged: {flagged}")
Topic Modeling with NMF
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import NMF, LatentDirichletAllocation
import numpy as np
# Sample news headlines
documents = [
"president signs climate change bill congress vote",
"senate approves new immigration reform legislation",
"election results show new party wins majority seats",
"bitcoin ethereum cryptocurrency market price rally",
"stock market dow jones record high earnings report",
"interest rates inflation federal reserve monetary policy",
"machine learning artificial intelligence deep learning model",
"neural network transformer architecture language model training",
"data science python pandas numpy scikit learn tutorial",
"football championship game team score touchdown victory",
"basketball playoffs game winner championship ring trophy",
"soccer world cup final match penalty kick goal",
]
# TF-IDF vectorization
vectorizer = TfidfVectorizer(max_features=100, stop_words="english")
X_tfidf = vectorizer.fit_transform(documents)
feature_names = vectorizer.get_feature_names_out()
# NMF: decomposes into topics (non-negative matrix factorization)
N_TOPICS = 4
nmf = NMF(n_components=N_TOPICS, random_state=42, max_iter=200)
W = nmf.fit_transform(X_tfidf) # document-topic matrix
H = nmf.components_ # topic-word matrix
def print_top_words(model, feature_names, n_top=8):
for topic_idx, topic in enumerate(model.components_):
top_words = [feature_names[i] for i in topic.argsort()[::-1][:n_top]]
print(f" Topic {topic_idx}: {', '.join(top_words)}")
print("NMF Topics:")
print_top_words(nmf, feature_names)
# Assign documents to dominant topic
dominant_topic = W.argmax(axis=1)
for doc, topic in zip(documents, dominant_topic):
print(f" [{topic}] {doc[:60]}") Frequently Asked Questions
How do I evaluate unsupervised models without labels?
For clustering: use silhouette score, Davies-Bouldin index, or Calinski-Harabász score. For dimensionality reduction: check how much variance is preserved (PCA explained variance ratio), or evaluate downstream task performance. For anomaly detection: precision/recall on a labeled holdout if you have one, otherwise tune contamination on domain knowledge.
When should I use PCA vs UMAP vs t-SNE?
PCA: for preprocessing (removing noise, compressing features before a classifier), and when you need linear interpretability. UMAP: for visualization of high-dimensional data — preserves global structure better than t-SNE and is much faster. t-SNE: older, slower, only for visualization. UMAP is almost always the better choice over t-SNE.