EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Dropping correlated features

Correlated features are two or more columns that move together closely enough to say the same thing, so keeping all of them adds cost and splits the credit for one effect.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

Dropping constant features removed columns that say nothing. This one removes columns that say something another column has already said. The Ames house price data is used throughout, because a house's garage size in cars and its garage size in square feet are the same fact written twice.

A note on a dataset you will meet in older material: load_boston was the usual example for this lesson and was removed from scikit-learn in version 1.2. Its B column encodes the proportion of Black residents per town as a feature assumed to affect house prices, and the maintainers removed the loader rather than keep shipping it. Importing it now raises ImportError with that reason. The Ames data makes the same point with no such problem.

A scatter plot of GarageCars, a count, against GarageArea in square feet on the Ames data. The points form five vertical groups, one per car count, climbing along a dashed straight line, with the correlation labelled 0.88. Text beside it explains that keeping both adds no information and splits the credit for one effect between two columns, so neither looks important. A panel below compares the two columns' correlation with SalePrice, 0.640 for GarageCars and 0.623 for GarageArea, and advises keeping the one closer to the target.

Syntax: corr builds the square matrix of Pearson correlations between every pair of columns. numeric_only=True is required on a frame that has text columns, because the default tries to convert them and raises.

python
matrix = df.corr(numeric_only=True)   # one row and one column per numeric column
matrix.loc["GarageCars", "GarageArea"]

Finding the pairs that move together

Ranking every pair by correlation

A 36 by 36 matrix is 1296 numbers, most of them uninteresting. Taking the upper triangle and sorting by size turns it into a short list of pairs worth a decision.

python
import numpy as np

matrix = X.corr(numeric_only=True).abs()
upper = np.triu(np.ones(matrix.shape), 1).astype(bool)   # above the diagonal
pairs = matrix.where(upper).stack().sort_values(ascending=False)
ExampleThe eight most closely related pairs in the Ames data
import numpy as np
import pandas as pd

AMES = ("https://raw.githubusercontent.com/krishnaik06/"
        "Advanced-House-Price-Prediction-/master/train.csv")
df = pd.read_csv(AMES)

numeric = df.select_dtypes(include="number").drop(columns=["Id"])
X = numeric.drop(columns=["SalePrice"])
y = numeric["SalePrice"]

matrix = X.corr(numeric_only=True).abs()
upper = np.triu(np.ones(matrix.shape), 1).astype(bool)
pairs = matrix.where(upper).stack().sort_values(ascending=False)

print("numeric feature columns:", X.shape[1])
print()
print(pairs.head(8).round(4).to_string())
  • GarageCars and GarageArea correlate at 0.88. One counts cars, the other measures square feet, and a bigger garage holds more cars.
  • YearBuilt and GarageYrBlt correlate at 0.83, because most garages were built with the house.
  • GrLivArea and TotRmsAbvGrd correlate at 0.83, and TotalBsmtSF and 1stFlrSF at 0.82, which on a house with no second floor are nearly the same number.
  • Nothing reaches 0.95. On this data there is no pair where one column is a relabelled copy of another, which is common on raw data from a feed.

Seeing the matrix as a picture

A heatmap over all 36 columns is unreadable. Over the ten columns most closely related to the target it shows both the pairs to decide about and how each column relates to what is being predicted.

ExampleThe first row is each column against the target
import matplotlib.pyplot as plt

top = numeric.corr(numeric_only=True)["SalePrice"].abs().sort_values(ascending=False).head(11)
block = numeric[top.index].corr(numeric_only=True)

fig, ax = plt.subplots(figsize=(8, 6.5))
image = ax.imshow(block, cmap="RdYlGn", vmin=-1, vmax=1)
ax.set_xticks(range(len(block)), block.columns, rotation=90)
ax.set_yticks(range(len(block)), block.columns)
for i in range(len(block)):
    for j in range(len(block)):
        ax.text(j, i, f"{block.iloc[i, j]:.2f}", ha="center", va="center", fontsize=7)
fig.colorbar(image, ax=ax, shrink=0.8)
ax.set_title("Correlation among the 10 columns closest to SalePrice")
plt.show()
print("columns drawn:", len(block))
An eleven by eleven correlation heatmap over SalePrice and the ten numeric columns most correlated with it, coloured from red at minus one to dark green at one, with each correlation printed in its cell. The first row shows OverallQual at 0.79 and GrLivArea at 0.71 against SalePrice. Inside the block, GarageCars and GarageArea stand out at 0.88, GrLivArea and TotRmsAbvGrd at 0.83, and TotalBsmtSF and 1stFlrSF at 0.82.

Dropping one column from each pair

Collecting the columns to remove

Walking the lower triangle of the matrix and collecting the later column of every pair above the threshold gives a set of names to drop. The set takes care of a column that is correlated with several others, so it is only collected once.

python
def correlated_columns(frame, threshold):
    keep_out = set()
    matrix = frame.corr(numeric_only=True)
    for i in range(len(matrix.columns)):
        for j in range(i):                       # the lower triangle only
            if abs(matrix.iloc[i, j]) > threshold:
                keep_out.add(matrix.columns[i])  # drop the later of the pair
    return keep_out
ExampleOne function, four thresholds
def correlated_columns(frame, threshold):
    keep_out = set()
    matrix = frame.corr(numeric_only=True)
    for i in range(len(matrix.columns)):
        for j in range(i):                       # the lower triangle only
            if abs(matrix.iloc[i, j]) > threshold:
                keep_out.add(matrix.columns[i])  # drop the later of the pair
    return keep_out

for threshold in [0.9, 0.8, 0.7, 0.6]:
    found = correlated_columns(X, threshold)
    print(f"threshold {threshold}: {len(found)} columns -> {sorted(found)}")
  • At 0.9 nothing is dropped, because no pair in this data is that close.
  • At 0.8 four columns go, one from each of the four pairs above. 0.8 and 0.7 give the same answer, which is a sign the threshold is in a stable range.
  • At 0.6 nine columns go, and the list now includes GrLivArea and GarageCars, two of the strongest predictors in the dataset. A threshold set too low removes signal, not duplication.
  • 0.8 to 0.9 is the usual range for this. Below 0.7 you are no longer removing duplicates.

Choosing which column of the pair to keep

The function above keeps whichever column appears first in the frame, which is an accident of column order. A better rule is to keep the one that correlates more strongly with the target, and that choice needs one extra line.

ExampleEach pair judged against the target rather than by column order
against_target = numeric.corr(numeric_only=True)["SalePrice"].abs()

for a, b in [("GarageCars", "GarageArea"), ("TotalBsmtSF", "1stFlrSF"),
             ("GrLivArea", "TotRmsAbvGrd"), ("YearBuilt", "GarageYrBlt")]:
    better = a if against_target[a] >= against_target[b] else b
    print(f"{a:14s} {against_target[a]:.4f}   {b:14s} {against_target[b]:.4f}"
          f"   keep {better}")

On this dataset the two rules agree on every pair, because the stronger column happens to come first each time. That is luck, and it will not hold on a frame whose columns arrive in a different order. Judging against the target costs one line and removes the luck.

Dropping a correlated column against keeping both

Drop one of the pairKeep both
Information lostAlmost none; the other column carries itNone
Linear model coefficientsStable and readableShared between the two, and unstable from run to run
Tree model accuracyUsually unchangedUsually unchanged
Feature importanceOne clear numberSplit, so both look weaker than they are
Columns to collect in productionFewerMore
RiskDropping the better of the two by accidentCarrying cost and confusing readers

Where this check matters most

  • Before a linear model. Two highly correlated columns make the coefficients unstable and their signs unreliable, which is the problem known as multicollinearity.
  • Before explaining a model. Split importance makes a strong feature look weak, and someone will draw the wrong conclusion from the chart.
  • After joining tables. A join is where exact duplicates appear, under two different names, with a correlation of 1.0.
Watch out. df.corr() without numeric_only=True raises ValueError: could not convert string to float: 'RL' on any frame with a text column. Pearson correlation also only finds straight-line relationships: two columns can move together perfectly and still correlate at zero, which is what Mutual information picks up.
Try it yourself
  • Pass method="spearman" to corr in the helper and compare the list at 0.8; Spearman ranks the values first, so it catches curved relationships Pearson misses.
  • Rewrite correlated_columns so it drops whichever column of the pair has the weaker correlation with SalePrice, and check that it still returns four names at 0.8.
  • Fit a LinearRegression on GarageCars and GarageArea together, print the two coefficients, then fit on each alone and compare.

This is what real progress feels like.