Lab-9 (Multiple Linear Regression)¶

(Intro. to Data Science: IIT Delhi - Abu Dhabi)¶

In this lab, we will work with real datasets to study important ideas around multiple linear regression:

  • fitting and interpreting a multiple linear regression model,
  • implementing gradient descent for least squares,
  • understanding the bias–variance tradeoff,
  • using polynomial regression to capture nonlinearity,
  • selecting predictor variables,
  • evaluating models using train/test error and cross-validation.

We will use the Diabetes dataset from scikit-learn for much of the discussion:

  • Number of observations: 442
  • Number of predictors: 10
  • Response: a quantitative measure of disease progression one year after baseline
  • Predictors: age, sex, body mass index, blood pressure, and six blood serum measurements

This is a real medical dataset, and it is well-suited for multiple linear regression because:

  • the response is numerical,
  • there are multiple predictors,
  • some predictors may be more useful than others,
  • the relationship may not be perfectly linear.

Since our target variable is continuous, we will evaluate models using measures such as:

  • Mean Squared Error (MSE),
  • Root Mean Squared Error (RMSE),
  • (R^2),
  • cross-validation error.
In [1]:
import numpy as np
import pandas as pd
from matplotlib.pyplot import subplots
from sklearn.datasets import load_diabetes

# We have already seen some of the modules and functions below
# Please find out about those you have not seen before
from sklearn.model_selection import train_test_split, cross_val_score, KFold
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.feature_selection import SequentialFeatureSelector
In [62]:
# Let us load the diabetes data and do a high-level inspection
diabetes = load_diabetes(as_frame=True)
X = diabetes.data.copy()
y = diabetes.target.copy()

df = X.copy()
df["target"] = y

print("Shape of X:", X.shape)
print("\nFeature names:")
print(list(X.columns))
print("\nFirst five rows:")
display(df.head())
print("\nSummary statistics:")
display(df.describe())
Shape of X: (442, 10)

Feature names:
['age', 'sex', 'bmi', 'bp', 's1', 's2', 's3', 's4', 's5', 's6']

First five rows:
age sex bmi bp s1 s2 s3 s4 s5 s6 target
0 0.038076 0.050680 0.061696 0.021872 -0.044223 -0.034821 -0.043401 -0.002592 0.019907 -0.017646 151.0
1 -0.001882 -0.044642 -0.051474 -0.026328 -0.008449 -0.019163 0.074412 -0.039493 -0.068332 -0.092204 75.0
2 0.085299 0.050680 0.044451 -0.005670 -0.045599 -0.034194 -0.032356 -0.002592 0.002861 -0.025930 141.0
3 -0.089063 -0.044642 -0.011595 -0.036656 0.012191 0.024991 -0.036038 0.034309 0.022688 -0.009362 206.0
4 0.005383 -0.044642 -0.036385 0.021872 0.003935 0.015596 0.008142 -0.002592 -0.031988 -0.046641 135.0
Summary statistics:
age sex bmi bp s1 s2 s3 s4 s5 s6 target
count 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 4.420000e+02 442.000000
mean -2.511817e-19 1.230790e-17 -2.245564e-16 -4.797570e-17 -1.381499e-17 3.918434e-17 -5.777179e-18 -9.042540e-18 9.293722e-17 1.130318e-17 152.133484
std 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 4.761905e-02 77.093005
min -1.072256e-01 -4.464164e-02 -9.027530e-02 -1.123988e-01 -1.267807e-01 -1.156131e-01 -1.023071e-01 -7.639450e-02 -1.260971e-01 -1.377672e-01 25.000000
25% -3.729927e-02 -4.464164e-02 -3.422907e-02 -3.665608e-02 -3.424784e-02 -3.035840e-02 -3.511716e-02 -3.949338e-02 -3.324559e-02 -3.317903e-02 87.000000
50% 5.383060e-03 -4.464164e-02 -7.283766e-03 -5.670422e-03 -4.320866e-03 -3.819065e-03 -6.584468e-03 -2.592262e-03 -1.947171e-03 -1.077698e-03 140.500000
75% 3.807591e-02 5.068012e-02 3.124802e-02 3.564379e-02 2.835801e-02 2.984439e-02 2.931150e-02 3.430886e-02 3.243232e-02 2.791705e-02 211.500000
max 1.107267e-01 5.068012e-02 1.705552e-01 1.320436e-01 1.539137e-01 1.987880e-01 1.811791e-01 1.852344e-01 1.335973e-01 1.356118e-01 346.000000

9.1 Solving optimization problem¶

Before starting to use the Python linear regression modules, let's build our own function for fitting the data using the concepts related to solving a convex optimization problem that we discussed in class. For the first exercise, we shall use all 10 numerical predictor variables and fit the entire dataset. We shall predict using the following equation: $\hat{y}_i = \theta_0 + \theta_1 \cdot x_{1, i} + ... + \theta_{10} \cdot x_{10, i}$.

In [63]:
# We already have the predictors in the matrix X. 
# We need to add the intercept column with ones.
X = np.insert(X, 0, np.ones(X.shape[0]), axis=1)
# We shall use the closed-form solution that we derived in class
Z = X.T@X
theta = np.linalg.inv(Z)@X.T@y
theta = np.array(theta)

# Let us calculate the error
residuals = y - X@theta
MSE = np.mean(residuals**2)
print('MSE = ', MSE)
print('theta = ', theta)
MSE =  2859.69634758675
theta =  [ 152.13348416  -10.0098663  -239.81564367  519.84592005  324.3846455
 -792.17563855  476.73902101  101.04326794  177.06323767  751.27369956
   67.62669218]

Next, let's try to solve the optimization problem using gradient descent. We have seen how important scaling of the data is for gradient descent. So, we shall start with scaling the data. We shall use the StandardScaler function in the sklearn.preprocessing module to convert the attributes to standard units.

In [64]:
from sklearn.preprocessing import StandardScaler

# Standardize the training and test predictors for gradient descent
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Let's not forget to set the intercept column back to all ones.
X_scaled[:, 0] = np.ones(X.shape[0])
X_scaled
Out[64]:
array([[ 1.        ,  0.80050009,  1.06548848, ..., -0.05449919,
         0.41853093, -0.37098854],
       [ 1.        , -0.03956713, -0.93853666, ..., -0.83030083,
        -1.43658851, -1.93847913],
       [ 1.        ,  1.79330681,  1.06548848, ..., -0.05449919,
         0.06015558, -0.54515416],
       ...,
       [ 1.        ,  0.87686984,  1.06548848, ..., -0.23293356,
        -0.98564884,  0.32567395],
       [ 1.        , -0.9560041 , -0.93853666, ...,  0.55838411,
         0.93616291, -0.54515416],
       [ 1.        , -0.9560041 , -0.93853666, ..., -0.83030083,
        -0.08875225,  0.06442552]])

We shall create a function that implements the gradient descent algorithm.

In [65]:
def gradient_descent(Xmat, yvec, lr=0.05, n_iter=2000):
    n, p = Xmat.shape
    theta = np.zeros(p)
    history = []

    for _ in range(n_iter):
        gradient = (2 / n) * Xmat.T @ (Xmat @ theta - yvec)
        theta = theta - lr * gradient
        mse_loss = np.mean((yvec - Xmat @ theta)**2)
        history.append(mse_loss)

    return theta, history

We saw in class how sensitive GD is to learning rate. So, let us try a few learning rates and observe the convergence.

In [66]:
# Try several learning rates
learning_rates = [0.001, 0.01, 0.05, 0.1]
results = {}

for lr in learning_rates:
    theta_hat, history = gradient_descent(X_scaled, y, lr=lr, n_iter=3000)
    results[lr] = (theta_hat, history)

fig, ax = subplots(figsize=(8, 5))
for lr, (_, history) in results.items():
    ax.plot(history, label=f"lr={lr}")
ax.set_xlabel("Iteration")
ax.set_ylabel("Training MSE")
ax.set_title("Gradient descent convergence for different learning rates")
ax.legend()
Out[66]:
<matplotlib.legend.Legend at 0x1212ebf10>
No description has been provided for this image
In [67]:
# Let us print the MSEs for different learning rates
for lr in learning_rates:
    print('MSE for lr=', lr, 'is', results[lr][1][len(results[lr][1])-1], 'and theta = ',
         results[lr][0])
MSE for lr= 0.001 is 2880.4429471103517 and theta =  [151.75864179  -0.30200546 -11.25543845  25.11925483  15.3004405
  -3.32916817  -5.26002714  -9.74444376   5.52395046  22.22502029
   3.64080951]
MSE for lr= 0.01 is 2867.708933668896 and theta =  [152.13348416  -0.37608323 -11.29493935  24.98004303  15.33158009
 -15.96660941   5.43606419  -4.89225062   5.67190048  27.63822932
   3.29611669]
MSE for lr= 0.05 is 2859.8276494650745 and theta =  [152.13348416  -0.46334361 -11.39253135  24.75884834  15.41678469
 -34.90023404  20.47050174   3.56291498   8.06723443  34.69870704
   3.22689774]
MSE for lr= 0.1 is 2859.6971143759492 and theta =  [152.13348416  -0.47514436 -11.40577141  24.72901716  15.42843976
 -37.46752872  22.5076079    4.71113201   8.39492544  35.65529544
   3.21745503]

Now that we know that even a high learning rate of 0.1 works, we can use it for further analysis.

9.2 Training and test set¶

Until now, we have tried to fit the entire labeled dataset into our linear regression model. However, this does not serve our key agenda, which is to come up with a prediction model that works well on new observations that are not part of the training set. To get a sense of how our prediction model will behave on a new observation, we split the training data into a training set and a test set. This is not a new idea since we have done this for the k-NN model in the previous lab as well. We'll create an 80:20 train:test split, train the linear regression model using the training set, and then test it on the test set.

In [41]:
# Let us restart with the diabetes dataset
X = diabetes.data.copy()
y = diabetes.target.copy()

# Split the data and fit a baseline multiple linear regression model
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Let us scale the dataset using the standard scaler. Even though scaling was crucial for GD,
# scaling is not required when we use the linear regression function that is part of the
# sklearn.linear_model module. 
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
X_train_scaled = np.insert(X_train_scaled, 0, np.ones(X_train_scaled.shape[0]), axis=1)
X_test_scaled = np.insert(X_test_scaled, 0, np.ones(X_test_scaled.shape[0]), axis=1)
y_train_np = y_train.to_numpy() if hasattr(y_train, "to_numpy") else np.asarray(y_train)
y_test_np = y_test.to_numpy() if hasattr(y_test, "to_numpy") else np.asarray(y_test)

# We shall train our model using GD as well as using the sklearn library
theta_gd, history_gd = gradient_descent(X_train_scaled, y_train_np, lr=0.1, n_iter=3000)
# Let us print the theta vector and the MSE over the test and training sets
print('theta obtained from GD = ', theta_gd)

y_train_pred = X_train_scaled@theta_gd
y_test_pred = X_test_scaled@theta_gd
train_mse = mean_squared_error(y_train, y_train_pred)
test_mse = mean_squared_error(y_test, y_test_pred)
train_r2 = r2_score(y_train, y_train_pred)
test_r2 = r2_score(y_test, y_test_pred)


print("Training MSE:", train_mse)
print("Test MSE:", test_mse)
print("Training R^2:", train_r2)
print("Test R^2:", test_r2)
theta obtained from GD =  [153.73654391   1.7546429  -11.51146822  25.61024833  16.82786538
 -44.28638811  24.5138678    7.60530327  13.11647069  35.09884904
   2.3525401 ]
Training MSE: 2868.5501836770923
Test MSE: 2900.0593062621933
Training R^2: 0.5279193072034936
Test R^2: 0.4526281156302925

Let's now repeat the exercise using library calls to the LinearRegression function. Note that we need not scale the data.

In [71]:
# Let us restart with the diabetes dataset
X = diabetes.data.copy()
y = diabetes.target.copy()

# Split the data and fit a baseline multiple linear regression model
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Let us now fit the training data 
linreg = LinearRegression()
linreg.fit(X_train, y_train)

y_train_pred = linreg.predict(X_train)
y_test_pred = linreg.predict(X_test)

train_mse = mean_squared_error(y_train, y_train_pred)
test_mse = mean_squared_error(y_test, y_test_pred)
train_r2 = r2_score(y_train, y_train_pred)
test_r2 = r2_score(y_test, y_test_pred)

print("Training MSE:", train_mse)
print("Test MSE:", test_mse)
print("Training R^2:", train_r2)
print("Test R^2:", test_r2)


# Let us display the coefficients table to see the magnitude of influence of 
# each predictor on the response
coef_table = pd.DataFrame({
    "predictor": X.columns,
    "coefficient": linreg.coef_
}).sort_values("coefficient", key=np.abs, ascending=False)

display(coef_table)
Training MSE: 2868.549702835578
Test MSE: 2900.1936284934827
Training R^2: 0.5279193863361497
Test R^2: 0.45260276297191926
predictor coefficient
4 s1 -931.488846
8 s5 736.198859
2 bmi 542.428759
5 s2 518.062277
3 bp 347.703844
7 s4 275.317902
1 sex -241.964362
6 s3 163.419983
9 s6 48.670657
0 age 37.904021

One thing we observe is that the R^2 for the test data is not as good as that of the training set. This indicates overfitting.

In [72]:
import matplotlib.pyplot as plt
# Residual analysis for the baseline linear model
residuals = y_test - y_test_pred

plt.figure(figsize=(4, 3))
plt.scatter(y_test, y_test_pred, alpha=0.7)
plt.xlabel("Actual target")
plt.ylabel("Predicted target")
plt.title("Actual vs Predicted (test set)")
plt.show()

plt.figure(figsize=(4, 3))
plt.scatter(y_test_pred, residuals, alpha=0.7)
plt.axhline(0, linestyle="--")
plt.xlabel("Predicted target")
plt.ylabel("Residual")
plt.title("Residuals vs Predicted (test set)")
plt.show()
No description has been provided for this image
No description has been provided for this image

9.3 Bias–variance tradeoff¶

In this section, we will explore the bias-variance tradeoff, i.e., the idea that:

  • simpler models may have high bias and low variance,
  • more flexible models may have low bias and high variance.

There are various ways to make the model more complex. One is considering a polynomial dependence rather than a linear dependence. We will use polynomial regression on a single predictor to make the tradeoff visible. The main ideas we explore in this section can also be considered later.

We know the theory for doing polynomial regression from scratch. If we are using two predictor variables $x_1, x_2$, and we do polynomial regression with degree 2, then we add three extra columns each for $x_1^2, x_2^2, (x_1\cdot x_2)$. We then find the coefficients corresponding to $intercept, x_1, x_2, x_1^1, x_2^2$, and $x_1\cdot x_2$ using the same techniques (i.e., closed form solution or GD). Instead of implementing this from scratch, we shall use the sklearn library called PolynomialFeatures that does precisely this. Here is a short write-up:

In scikit-learn, PolynomialFeatures is a preprocessing tool used to generate non-linear features by combining existing variables into polynomial and interaction terms. This allows linear models (like LinearRegression) to fit complex, non-linear data patterns.

PolynomialFeatures

Core Functionality: It transforms an input matrix into a new one containing all polynomial combinations of the features up to a specified degree.

  • Example: For input features $[a, b]$ at degree=2, it generates $[1, a, b, a^2, ab, b^2]$.
  • Expansion: The number of features grows polynomially with the number of inputs and exponentially with the degree.

Key Parameters:

  • degree (default=2): The maximum power for the features. Can be an integer or a tuple (min_degree, max_degree).
  • interaction_only (default=False): If set to True, only products of distinct features are produced (e.g., $ab$ is kept, but $a^2$ is excluded).
  • include_bias (default=True): Includes a column of ones, which acts as the intercept term in a linear model.

We will use the predictor bmi and s1 as the predictor variables and the diabetes progression score as the response. In the code below, we observe train/test MSE as a function of the polynomial degree. We observe that the train MSE decreases with degree whereas, the test MSE increases.

In [73]:
# Polynomial regression on two predictors
Xb = X[["bmi", "s1"]]
Xb_train, Xb_test, yb_train, yb_test = train_test_split(
    Xb, y, test_size=0.3, random_state=42
)

degrees = range(1, 8)
poly_results = []

for d in degrees:
    model = Pipeline([
        ("poly", PolynomialFeatures(degree=d, include_bias=False)),
        ("scaler", StandardScaler()),
        ("linreg", LinearRegression())
    ])
    model.fit(Xb_train, yb_train)
    yb_train_pred = model.predict(Xb_train)
    yb_test_pred = model.predict(Xb_test)

    poly_results.append({
        "degree": d,
        "train_mse": mean_squared_error(yb_train, yb_train_pred),
        "test_mse": mean_squared_error(yb_test, yb_test_pred)
    })

poly_df = pd.DataFrame(poly_results)
display(poly_df)

plt.figure(figsize=(6, 4))
plt.plot(poly_df["degree"], poly_df["train_mse"], marker="o", label="Train MSE")
plt.plot(poly_df["degree"], poly_df["test_mse"], marker="o", label="Test MSE")
plt.xlabel("Polynomial degree")
plt.ylabel("MSE")
plt.title("Bias–variance tradeoff via polynomial regression on BMI")
plt.legend()
plt.show()
degree train_mse test_mse
0 1 3890.245986 3832.733230
1 2 3868.095804 3898.145924
2 3 3827.598717 3800.167325
3 4 3775.686334 3924.791041
4 5 3742.001920 4367.414162
5 6 3675.593276 4518.595731
6 7 3600.815373 6018.642387
No description has been provided for this image

9.4 Cross-validation for model selection¶

A single train/test split can be misleading. We now use cross-validation to select model complexity more reliably. This should not be a new concept since we covered this in the previous lab.

We will use the same bmi, s1 predictor, and compute 5-fold cross-validation MSE for polynomial degrees: $1,2,3,4,5,6,7,8,9,10$.

In [74]:
# Polynomial regression on two predictors
Xb = X[["bmi", "s1"]]
# Let us keep aside a test set that we can use after model selection 
# using cross-validation
Xb_train, Xb_test, yb_train, yb_test = train_test_split(
    Xb, y, test_size=0.2, random_state=42
)

# Now, we will run the cross-validation on the training set
cv_results = []

for d in range(1, 11):
    model = Pipeline([
        ("poly", PolynomialFeatures(degree=d, include_bias=False)),
        ("scaler", StandardScaler()),
        ("linreg", LinearRegression())
    ])

    scores = cross_val_score(
        model,
        Xb_train,
        yb_train,
        cv = KFold(n_splits=5, shuffle=True, random_state=42),
        scoring="neg_mean_squared_error"
    )

    cv_results.append({
        "degree": d,
        "cv_mse_mean": -scores.mean(),
        "cv_mse_std": scores.std()
    })

cv_df = pd.DataFrame(cv_results)
display(cv_df)

plt.figure(figsize=(6, 4))
plt.plot(cv_df["degree"], cv_df["cv_mse_mean"], marker="o")
plt.xlabel("Polynomial degree")
plt.ylabel("Mean 5-fold CV MSE")
plt.title("Cross-validation for polynomial degree selection")
plt.show()
degree cv_mse_mean cv_mse_std
0 1 3.909985e+03 2.120624e+02
1 2 3.962487e+03 1.911091e+02
2 3 4.033647e+03 3.412430e+02
3 4 5.204778e+03 2.347937e+03
4 5 5.758134e+03 3.246469e+03
5 6 2.837413e+04 3.280303e+04
6 7 1.607620e+06 3.187779e+06
7 8 6.565720e+05 1.150962e+06
8 9 3.363120e+05 5.796066e+05
9 10 3.942730e+09 7.876418e+09
No description has been provided for this image

We observe that the the MSE is almost the same for $d=1, 2, 3$ and then there is a significant increase. So, we can pick $d=3$ for evaluation on the test set.

In [75]:
model = Pipeline([
        ("poly", PolynomialFeatures(degree=3, include_bias=False)),
        ("scaler", StandardScaler()),
        ("linreg", LinearRegression())
    ])

model.fit(Xb_train, yb_train)
yb_train_pred = model.predict(Xb_train)
yb_test_pred = model.predict(Xb_test)
print("test_mse: ", mean_squared_error(yb_test, yb_test_pred))
test_mse:  3915.878902705903

9.5 Feature Selection¶

An important question when doing multiple linear regression is:

Which predictors should we include?

Too few predictors may cause underfitting. Too many may add noise and hurt interpretability. We will try a simple forward sequential feature selection procedure. This is a greedy procedure that starts with no predictors (in forward selection) and greedily adds predictors based on the score (MSE or R^2) while doing cross-validation. A short write-up on the sklearn function is given below:

SequentialFeatureSelector (SFS) in scikit-learn is a greedy feature selection transformer that builds a feature subset by either adding or removing one feature at a time based on a model's cross-validation score.

Core Mechanisms:

  • Forward Selection: Starts with zero features and iteratively adds the "best" feature—the one that most improves the model's cross-validated performance—until the target number is reached.
  • Backward Selection: Starts with all features and iteratively removes the "least important" feature—the one whose removal least impacts or most improves the score—until the target number remains.

The code below is intuitive enough to make the usage of SequentialFeatureSelector clear.

In [48]:
# Let us restart with the diabetes dataset
X = diabetes.data.copy()
y = diabetes.target.copy()

# Split the data and fit a baseline multiple linear regression model
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Forward feature selection
base_estimator = LinearRegression()

sfs3 = SequentialFeatureSelector(
    base_estimator,
    n_features_to_select=3,
    direction="forward",
    #scoring="neg_mean_squared_error",
    scoring = "r2",
    cv=5
)
sfs5 = SequentialFeatureSelector(
    base_estimator,
    n_features_to_select=5,
    direction="forward",
    #scoring="neg_mean_squared_error",
    scoring = "r2",
    cv=5
)

sfs3.fit(X_train, y_train)
sfs5.fit(X_train, y_train)

selected_3 = X.columns[sfs3.get_support()].tolist()
selected_5 = X.columns[sfs5.get_support()].tolist()

print("Selected 3 predictors:", selected_3)
print("Selected 5 predictors:", selected_5)

def fit_and_evaluate(X_train_sub, X_test_sub, y_train, y_test):
    model = LinearRegression()
    model.fit(X_train_sub, y_train)
    pred = model.predict(X_test_sub)
    return {
        "test_mse": mean_squared_error(y_test, pred),
        "test_r2": r2_score(y_test, pred)
    }

all_results = fit_and_evaluate(X_train, X_test, y_train, y_test)
res3 = fit_and_evaluate(X_train[selected_3], X_test[selected_3], y_train, y_test)
res5 = fit_and_evaluate(X_train[selected_5], X_test[selected_5], y_train, y_test)

comparison = pd.DataFrame([
    {"model": "All 10 predictors", **all_results},
    {"model": "Selected 3 predictors", **res3},
    {"model": "Selected 5 predictors", **res5},
])

display(comparison)
Selected 3 predictors: ['bmi', 'bp', 's5']
Selected 5 predictors: ['sex', 'bmi', 'bp', 's3', 's5']
model test_mse test_r2
0 All 10 predictors 2900.193628 0.452603
1 Selected 3 predictors 2891.037211 0.454331
2 Selected 5 predictors 2811.385100 0.469365
In [49]:
# Try polynomial regression on a selected subset
subset = selected_3  # or choose your own list of predictors
poly2_model = Pipeline([
    ("poly", PolynomialFeatures(degree=2, include_bias=False)),
    ("linreg", LinearRegression())
])

poly2_model.fit(X_train[subset], y_train)
poly2_pred = poly2_model.predict(X_test[subset])

print("Selected subset:", subset)
print("Degree-2 polynomial test MSE:", mean_squared_error(y_test, poly2_pred))
print("Degree-2 polynomial test R^2:", r2_score(y_test, poly2_pred))
Selected subset: ['bmi', 'bp', 's5']
Degree-2 polynomial test MSE: 2824.8994726603664
Degree-2 polynomial test R^2: 0.4668141633634232

9.6 Ridge and Lasso¶

We saw in class that Ridge and Lasso regression allow us to obtain a good bias-variance tradeoff in multiple linear regression using a single parameter $\alpha$, which is called the regularization parameter. Let us check the effectiveness of these techniques for the diabetes dataset.

In [55]:
from sklearn.linear_model import RidgeCV, LassoCV

# Let us restart with the diabetes dataset
X = diabetes.data.copy()
y = diabetes.target.copy()

# Split the data and fit a baseline multiple linear regression model
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

alphas = np.logspace(-4, 4, 100)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

ridge_model = Pipeline([
    ("scaler", StandardScaler()),
    ("ridge", RidgeCV(alphas=alphas, cv=cv))#, scoring="neg_mean_squared_error"))
])

lasso_model = Pipeline([
    ("scaler", StandardScaler()),
    ("lasso", LassoCV(alphas=alphas, cv=cv, random_state=42, max_iter=10000))
])

ridge_model.fit(X_train, y_train)
lasso_model.fit(X_train, y_train)

ridge_pred = ridge_model.predict(X_test)
lasso_pred = lasso_model.predict(X_test)

print("Ridge best alpha:", ridge_model.named_steps["ridge"].alpha_)
print("Ridge RMSE:", np.sqrt(mean_squared_error(y_test, ridge_pred)))
print("Ridge R^2:", r2_score(y_test, ridge_pred))

print("Lasso best alpha:", lasso_model.named_steps["lasso"].alpha_)
print("Lasso RMSE:", np.sqrt(mean_squared_error(y_test, lasso_pred)))
print("Lasso R^2:", r2_score(y_test, lasso_pred))
Ridge best alpha: 0.9111627561154896
Ridge RMSE: 53.782216913587
Ridge R^2: 0.45404982842366015
Lasso best alpha: 0.521400828799969
Lasso RMSE: 53.44045229205649
Lasso R^2: 0.4609663753064137

9.7 Categorical predictors and one-hot encoding¶

As discussed in class, we must be careful when using categorical predictors. Performing multiple linear regression after assigning arbitrary numerical values to the categories may not give a good predictor. The solution is to use one-hot encoding that effectively allows us to consider the influence of different category classes on the response separately. For this discussion, we shall consider a housing dataset (please download) that has the "city name" as a categorical predictor variable.

In [ ]:
housing = pd.read_csv('sfhousing.csv')
data = housing[['city', 'bsqft', 'price']]

# We shall consider data only from four cities
four_cities = ["Berkeley", "Albany", "Piedmont", "Richmond"]
# Retain data for only the above four cities
data1 = data[data['city'].isin(four_cities)]
# Some price entries are non numeric. Changing the column to numeric
data1['price'] = pd.to_numeric(data1['price'], errors='coerce')
# Drop the rows with undefined entries
data2 = data1.dropna().reset_index(drop=True)
# Drop the rows with extremely large value of bsqft
data3 = data2[data2['bsqft'] < 8000].reset_index(drop=True)
data3.shape

Let's do a scatter plot of price versus bsqft and note that the price crucially depends on the city in addition to the bsqft.

In [20]:
B = data3[data3['city'] == 'Berkeley']
L = data3[data3['city'] == 'Albany']
P = data3[data3['city'] == 'Piedmont']
R = data3[data3['city'] == 'Richmond']

fig, ax = subplots(nrows=2, ncols=2, figsize = (10, 8))
ax[0, 0].scatter(B['bsqft'], B['price'])
ax[0, 0].set_title('Berkeley')
ax[0, 1].scatter(L['bsqft'], L['price'])
ax[0, 1].set_title('Albany')
ax[1, 0].scatter(P['bsqft'], P['price'])
ax[1, 0].set_title('Piedmont')
ax[1, 1].scatter(R['bsqft'], R['price'])
ax[1, 1].set_title('Richmond')
Out[20]:
Text(0.5, 1.0, 'Richmond')
No description has been provided for this image

The above scatter plots suggest that the city does indeed play an important part in determining the price. However, the ordering of the city in which they determine the price is not known. So, it is better to treat the city as a categorical predictor and use one-hot encoding for our linear regression. Please read the manual and review the code below to understand the mechanism.

In [21]:
from sklearn.preprocessing import OneHotEncoder
import numpy as np

# Initialize and fit
encoder = OneHotEncoder(sparse_output=False)
encoded_data = encoder.fit_transform(data3[['city']])

# Get the labels (e.g., 'category_column_value1', 'category_column_value2')
column_labels = encoder.get_feature_names_out(['city'])

# Create a labeled DataFrame
encoded_df = pd.DataFrame(encoded_data, columns=column_labels)
data4 = pd.concat([data3, encoded_df], axis=1)

# We can now work with the following X, y for our linear regression
y = data4['price']
X = data4[['bsqft', 'city_Albany', 'city_Berkeley', 'city_Piedmont', 'city_Richmond']]
X
Out[21]:
bsqft city_Albany city_Berkeley city_Piedmont city_Richmond
0 1178.0 1.0 0.0 0.0 0.0
1 1736.0 1.0 0.0 0.0 0.0
2 1736.0 1.0 0.0 0.0 0.0
3 1521.0 1.0 0.0 0.0 0.0
4 754.0 1.0 0.0 0.0 0.0
... ... ... ... ... ...
14030 3086.0 0.0 0.0 0.0 1.0
14031 2079.0 0.0 0.0 0.0 1.0
14032 2079.0 0.0 0.0 0.0 1.0
14033 2732.0 0.0 0.0 0.0 1.0
14034 2079.0 0.0 0.0 0.0 1.0

14035 rows × 5 columns

9.8 Project on Multiple Linear Regression (100 points)¶

You are asked to use all the concepts covered in this lab to design a predictor for a smaller version (with 30000 points) of the above housing dataset (also see this link). This dataset has been created for you in the code cell below.

In [22]:
X = housing[['county', 'city', 'zip', 'street', 'br', 'lsqft', 'bsqft', 'year', 'date']]
y = housing['price']

# create a smaller subset of 30,000 examples
X_small, _, y_small, _ = train_test_split(
    X, y, train_size=30000, random_state=42
)
# For this project you are asked to consider (X_small, y_small) as the entire labeled dataset available to you.
# Please feel free to create as many cells as required for this project

One of the aspects on which your classifier will be graded is R^2 (on a randomly chosen subset of the housing dataset). You also need to show and explain all your work on how you decided on your final predictor (including any data cleaning operations). Your understanding of various issues will determine your score on this minor project. You may create as many code/markdown cells to explain your work clearly. At the end, you should create a predictor named myPredictor(X) that takes as input a dataframe $X$ with features ('county', 'city', 'zip', 'street', 'br', 'lsqft', 'bsqft', 'year', 'date') and outputs a price prediction vector.

In [ ]: