Solution for Exercise M8.01#
In the Chapter on Linear Models we saw that feature engineering using
PolynomialFeatures can give a linear model the flexibility to capture
non-linear relationships, in particular, it is useful to model interactions
between features.
The downside is that the feature space grows quadratically with n_features,
and if the input features are correlated, those new features would be
correlated as well. Fitting a linear regressor on that many correlated
features can be unnecessarily slow and prone to overfitting, even with
regularization.
PCA after a polynomial expansion compresses that feature space into a smaller set of components that retain the flexibility from the non-linearly augmented feature space, before passing them to the final estimator.
In this exercise we explore whether we can tune n_components when it is used
as a preprocessing step in a supervised pipeline. For such purpose we use the
Ames Housing dataset, keeping only a subset of the numerical features.
import pandas as pd
import numpy as np
ames_housing = pd.read_csv("../datasets/ames_housing_no_missing.csv")
numerical_features = [
"LotFrontage",
"LotArea",
"MasVnrArea",
"BsmtFinSF1",
"BsmtFinSF2",
"BsmtUnfSF",
"TotalBsmtSF",
"1stFlrSF",
"2ndFlrSF",
"LowQualFinSF",
"GrLivArea",
"BedroomAbvGr",
"KitchenAbvGr",
"TotRmsAbvGrd",
"Fireplaces",
"GarageCars",
"GarageArea",
"WoodDeckSF",
"OpenPorchSF",
"EnclosedPorch",
"3SsnPorch",
"ScreenPorch",
"MiscVal",
]
target_name = "SalePrice"
data, target = (
ames_housing[numerical_features],
ames_housing[target_name],
)
target /= 1000
First, fit a PolynomialFeatures with degree=2 and include_bias=False to
the whole data to check how many features are produced by the polynomial
expansion. You can use the attributes n_features_in_ and
n_output_features_.
# solution
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=2, include_bias=False).fit(data)
print(f"Input features: {poly.n_features_in_}")
print(f"After degree-2 expansion: {poly.n_output_features_}")
Input features: 23
After degree-2 expansion: 299
With 299 features entering the ridge regressor, fit time becomes substantial. Since the original features are correlated, many of the polynomial terms are correlated as well. PCA can project them into a smaller set of uncorrelated components before fitting the predictor.
Build a pipeline with skrub.SquashingScaler using quantile_range=(5.0, 95.0), followed by the previous polynomial expansion, then PCA, and Ridge
regression with default parameters as the final predictor. Use GridSearchCV
with scoring set to βneg_root_mean_squared_errorβ to search over
n_components using the grid defined below. Fit it on the full dataset.
from sklearn.decomposition import PCA
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import GridSearchCV
from skrub import SquashingScaler
param_name = "pca__n_components"
param_grid = {param_name: [3, 10, 50, 100, 200, None]}
# solution
squashing = SquashingScaler(quantile_range=(5.0, 95.0))
pipeline = make_pipeline(squashing, poly, PCA(), Ridge())
grid_search = GridSearchCV(
pipeline,
param_grid=param_grid,
scoring="neg_root_mean_squared_error",
)
grid_search.fit(data, target)
GridSearchCV(estimator=Pipeline(steps=[('squashingscaler',
SquashingScaler(quantile_range=(5.0,
95.0))),
('polynomialfeatures',
PolynomialFeatures(include_bias=False)),
('pca', PCA()), ('ridge', Ridge())]),
param_grid={'pca__n_components': [3, 10, 50, 100, 200, None]},
scoring='neg_root_mean_squared_error')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
Parameters
Parameters
Parameters
Parameters
Build a grid_search_results DataFrame from the attribute cv_results_
keeping only the columns listed in results_columns below.
results_columns = [
"mean_test_score",
"std_test_score",
"mean_fit_time",
"std_fit_time",
"mean_score_time",
"std_score_time",
"param_" + param_name,
]
# solution
grid_search_results = pd.DataFrame(grid_search.cv_results_)[results_columns]
The following cell plots test RMSE against fit time using grid_search_results.
Hover over a point to see the corresponding n_components value. It runs
without modifications once grid_search_results is correctly defined.
import plotly.express as px
def plot_grid_search_results(grid_search_results):
grid_search_results["mean_test_error"] = -grid_search_results[
"mean_test_score"
]
grid_search_results = (
grid_search_results.drop(columns=["mean_test_score"])
.rename(columns={"param_" + param_name: "n_components"})
.round(2)
)
grid_search_results.sort_values("mean_test_error", ascending=False)
labels = {
"mean_fit_time": "CV fit time (s)",
"mean_test_error": "CV score (RMSE)",
}
grid_search_results["n_components"] = grid_search_results[
"n_components"
].fillna("None")
fig = px.scatter(
grid_search_results,
x="mean_fit_time",
y="mean_test_error",
error_x="std_fit_time",
error_y="std_test_score",
hover_data=grid_search_results.columns,
labels=labels,
)
fig.update_layout(
title={
"text": "Trade-off between fit time and mean test score",
"y": 0.95,
"x": 0.5,
"xanchor": "center",
"yanchor": "top",
}
)
fig.show(renderer="notebook")
# solution
plot_grid_search_results(grid_search_results)
In general, fit times can vary significantly across runs and machines. What tends to be stable is the general shape of the curve:
The test RMSE drops substantially up to around 100 components, after which the error bars of successive points overlap. This indicates that retaining more components does not meaningfully reduce the test error.
Fit time, however, increases steadily with
n_components.
The sweet spot is therefore the smallest n_components whose error bar overlaps with the best-performing point.
Does reducing dimensions stabilize the optimal alpha?#
During the first part of this exercise we fixed alpha to focus on the
fit-time tradeoff. As we saw in the chapter on linear models, the optimal
regularization strength is not necessarily the same on all cross-validation
iterations. Here we ask whether reducing the number of components makes the
choice of alpha more stable across folds.
Replace the Ridge from your previous pipeline to use
RidgeCV(alphas=alphas). This replaces the grid search over alpha with a
faster internal selection, so we only need to loop over n_components=[50, 100, None] by hand. For each component, cross_validate using
ShuffleSplit (as below) and set return_estimator in the cross_validate
function to collect the alpha_ attribute at each split and store them in
best_alphas.
Does reducing dimensions make the optimal alpha more consistent across
folds?
from collections import defaultdict
from sklearn.linear_model import RidgeCV
from sklearn.model_selection import ShuffleSplit
from sklearn.model_selection import cross_validate
alphas = np.logspace(-2, 2, 21)
best_alphas = defaultdict(list)
cv = ShuffleSplit(n_splits=50, random_state=0)
# solution
n_components_cases = [50, 100, None]
pipeline = make_pipeline(squashing, poly, PCA(), RidgeCV(alphas=alphas))
for n_components in n_components_cases:
pipeline.set_params(pca__n_components=n_components)
cv_results = cross_validate(
pipeline,
data,
target,
cv=cv,
return_estimator=True,
scoring="neg_root_mean_squared_error",
)
for est in cv_results["estimator"]:
best_alphas[str(n_components)].append(est[-1].alpha_)
Make a boxplot of best_alphas using a βlogβ yscale.
# solution
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 5))
ax.boxplot(
[best_alphas[str(k)] for k in n_components_cases],
tick_labels=[str(k) for k in n_components_cases],
)
ax.set_yscale("log")
ax.set_xlabel("n_components")
ax.set_ylabel("Optimal alpha")
_ = ax.set_title("Stability of optimal alpha across outer folds")
With 50 components the interquartile range spans several orders of
magnitude, while at 100 and None the distributions are compact and similar
enough to each other. At 50 components the PCA representation is sensitive to
which samples fall in the training fold: a different subsample changes which
directions are selected, and RidgeCV compensates with a very different alpha.
Instead of changing the random_state, feel free to increase n_splits to
see whether the shape of the boxes changes. Just keep in mind that increases
the running time.