Principal Component Analysis
Directions of greatest variance, reading loadings, and why scaling comes first
Week 5 showed distances losing meaning as dimensions grow. Week 8 showed noise columns costing real accuracy. Both problems have the same treatment: fewer, better dimensions.
Why fewer dimensions
- Distance stops working: every point becomes equidistant, which breaks kNN and clustering.
- Data gets sparse: the volume you need to fill grows exponentially with dimensions.
- Correlated columns are redundant: tenure and total charges carry much of the same information.
- You cannot look at it, and looking is how you notice things no metric reports.
PCA, in one idea
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
prep = make_pipeline(SimpleImputer(strategy='median'), StandardScaler())
Z = prep.fit_transform(df[NUM])
from sklearn.decomposition import PCA
import numpy as np
pca = PCA().fit(Z)
print('%10s %14s %16s' % ('component', 'variance', 'cumulative'))
cum = np.cumsum(pca.explained_variance_ratio_)
for i, (v, c) in enumerate(zip(pca.explained_variance_ratio_, cum), 1):
print('%10d %14.4f %16.4f' % (i, v, c))
1 0.4893 0.4893
2 0.2770 0.7663
3 0.2176 0.9839
4 0.0161 1.0000
Four columns in, and the first two components carry most of the variance. That is what correlation looks like from the other side: tenure_months and total_charges correlate at 0.83, so they are largely one direction wearing two names.
Reading the components
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
prep = make_pipeline(SimpleImputer(strategy='median'), StandardScaler())
Z = prep.fit_transform(df[NUM])
from sklearn.decomposition import PCA
import pandas as pd
pca = PCA(n_components=3).fit(Z)
loadings = pd.DataFrame(pca.components_.T, index=NUM,
columns=['PC1', 'PC2', 'PC3'])
print(loadings.round(3).to_string())
tenure_months 0.610 -0.415 0.255
support_calls 0.147 0.668 0.729
monthly_charges 0.343 0.613 -0.631
total_charges 0.699 -0.079 -0.066
Loadings are how you name a component
PC1 loads heavily and in the same direction on tenure and total charges. It is a customer lifetime axis. PC2 is dominated by monthly charges and support calls, which is closer to current spend and friction. Components are not automatically meaningful, but when they are, saying so is worth more than the variance figure.
Scaling first is mandatory
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges', 'total_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.decomposition import PCA
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
raw = SimpleImputer(strategy='median').fit_transform(df[NUM])
scaled = StandardScaler().fit_transform(raw)
for name, data in [('unscaled', raw), ('scaled ', scaled)]:
p_ = PCA(n_components=2).fit(data)
print('%s PC1 explains %.1f%% loadings %s'
% (name, 100 * p_.explained_variance_ratio_[0],
p_.components_[0].round(3)))
scaled PC1 explains 48.9% loadings [0.61 0.147 0.343 0.699]
Unscaled, PC1 is just your largest column
PCA maximises variance, and variance depends on units. total_charges has a variance thousands of times larger than support_calls purely because it is measured in pounds rather than counts, so the first component points almost entirely along it and explains nearly everything. Change total charges to thousands of pounds and the answer changes. Always scale first.
On data where it earns its keep
digits = load_digits()
Xd, yd = digits.data, digits.target
from sklearn.decomposition import PCA
import numpy as np
from sklearn.preprocessing import StandardScaler
print('handwritten digits: %d images, %d pixels each' % Xd.shape)
pca = PCA().fit(StandardScaler().fit_transform(Xd))
cum = np.cumsum(pca.explained_variance_ratio_)
for target in [0.80, 0.90, 0.95, 0.99]:
k = int(np.searchsorted(cum, target)) + 1
print(' %.0f%% of the variance needs %3d of 64 components'
% (100 * target, k))
80% of the variance needs 21 of 64 components
90% of the variance needs 31 of 64 components
95% of the variance needs 40 of 64 components
99% of the variance needs 54 of 64 components
Ninety-five percent of what distinguishes these images survives in 40 numbers instead of 64, and 80 percent in 21. Neighbouring pixels are highly correlated. That is what an image is, and PCA turns that redundancy into a smaller representation.