The Machine Learning They Didn't Teach You

Chapter 6

Inside the linear regression

Table of contents

In Training the first model, I wrote “I will explain the linear regression model later” and went straight to the metrics. Since then, we have called fit() hundreds of times (or, at least, twenty times), built a pipeline around it, and argued about its coefficients for a whole chapter. Then, I kept saying things like “a least-squares fit makes its errors average out to exactly 0” and expected you to take my word for it. This chapter opens the model and checks those claims. I won’t dig into every kind of model, but since I promised you an explanation of the strange terms I used, here it is.

Why look inside the model?

In the case of linear regression, a handful of numbers and one equation are the entire model. Not scikit-learn, not any other library you use, just the math and the values. Once the training is over, all that’s left to do is multiplication and addition, and computers could do both long before anyone wrote a line of Python.

Imagine you have to run the model on an embedded device, like the microcontroller inside a washing machine. You don’t need to buy hardware that can run Python. You train the model in Python and write the equation in C with the coefficients as constants. It runs blazingly fast, without the overhead of an environment capable of running scikit-learn in a washing machine. It’s also much, much cheaper, and somebody has to pay for the extra memory in a million washing machines.

Simple models aren’t useless, either. Remember the tiny neural networks from the model selection step in the machine learning process? Fewer than 500 neurons in total. Those things made real money and kept the company afloat. Simple models have one more advantage. If you ever need to explain the model’s decisions to a regulator, you can understand what happens inside a simple model and describe it to other people. Good luck doing that with a model whose parameters you count in billions.

What is inside the model?

A fitted LinearRegression keeps one number per column in coef_ and one more in intercept_. To predict, it multiplies every column by its number, adds up the products, and adds the constant:

\[\hat{y} = a_1 x_1 + a_2 x_2 + \dots + a_p x_p + b\]

A coefficient (\(a_1\) to \(a_p\)) is the number the model multiplies a column by. It tells you how much the prediction changes when that column grows by one unit and every other column stays where it was. The intercept (\(b\)) is the constant added at the end.

Let’s put our final pipeline in the washing machine. It’s more than the regression, though. The scaler subtracted a mean and divided by a standard deviation it learned, the encoder decided which makes get their own column, and the predictions come out in gallons per 100 miles. If you copy only coef_ and intercept_, the firmware will multiply raw pounds by a number meant for standardized ones, and nothing will crash to tell you.

So we need these numbers too:

regression = final.regressor_
scaler = regression[1].named_transformers_['numeric']

pd.DataFrame({'mean': scaler.mean_, 'standard deviation': scaler.scale_}, index=kept).to_markdown()
  mean standard deviation
horsepower 111.228 40.024
weight 3118.63 880.068
acceleration 15.2453 2.75151
pd.concat([
    pd.Series({'intercept': regression[-1].intercept_}),
    pd.Series(regression[-1].coef_, index=regression[1].get_feature_names_out()),
]).to_markdown()
  0
intercept 5.53071
horsepower 0.922413
weight 0.738625
acceleration 0.242859
make_buick -0.671241
make_chevrolet -0.157074
make_datsun -0.643158
make_dodge -0.482201
make_ford -0.0223025
make_mercury -0.13972
make_plymouth -0.37409
make_pontiac -1.01458
make_toyota -0.324366
make_volkswagen -0.106506
make_infrequent_sklearn -0.443638

The coefficients are in gallons per 100 miles, so a positive one means more fuel. AMC is the make drop='first' removed, and it lives in the intercept. Every rare make, and every make the encoder has never seen, gets the infrequent coefficient.

Here is the whole pipeline in C:

#include <string.h>

static const char *MAKES[] = {
    "buick", "chevrolet", "datsun", "dodge", "ford",
    "mercury", "plymouth", "pontiac", "toyota", "volkswagen",
};
static const double MAKE_COEFFICIENTS[] = {
    -0.671241, -0.157074, -0.643158, -0.482201, -0.0223025,
    -0.13972, -0.37409, -1.01458, -0.324366, -0.106506,
};
static const double INFREQUENT_MAKE = -0.443638;

static const char *MAKE_SPELLINGS[][2] = {
    {"chevy", "chevrolet"}, {"chevroelt", "chevrolet"}, {"maxda", "mazda"},
    {"toyouta", "toyota"}, {"vw", "volkswagen"}, {"mercedes", "mercedes-benz"},
    {"hi", "international harvester"}, {"capri", "mercury"},
};

static const char *add_make(const char *car_name, char *first_word, size_t size) {
    size_t length = strcspn(car_name, " ");
    if (length >= size) {
        length = size - 1;
    }
    memcpy(first_word, car_name, length);
    first_word[length] = '\0';
    for (int i = 0; i < 8; i++) {
        if (strcmp(first_word, MAKE_SPELLINGS[i][0]) == 0) {
            return MAKE_SPELLINGS[i][1];
        }
    }
    return first_word;
}

/* AMC is the dropped make, so its effect lives in the intercept */
static double make_coefficient(const char *make) {
    for (int i = 0; i < 10; i++) {
        if (strcmp(make, MAKES[i]) == 0) {
            return MAKE_COEFFICIENTS[i];
        }
    }
    return strcmp(make, "amc") == 0 ? 0.0 : INFREQUENT_MAKE;
}

double predict_mpg(double horsepower, double weight, double acceleration, const char *car_name) {
    char first_word[32];
    const char *make = add_make(car_name, first_word, sizeof first_word);
    double gallons_per_100_miles = 5.53071
        + 0.922413 * (horsepower - 111.228) / 40.024
        + 0.738625 * (weight - 3118.63) / 880.068
        + 0.242859 * (acceleration - 15.2453) / 2.75151
        + make_coefficient(make);
    return 100.0 / gallons_per_100_miles;
}

For the Skoda Citigo (74 hp, 1898 lbs, 12.8 s, and a make the encoder has never seen), predict_mpg(74, 1898, 12.8, "skoda citigo") comes out at 33.46 mpg, the same as final.predict() in The Skodas in PCA space. No Python, no scikit-learn, and the whole model fits in the memory of a 50-cent microcontroller. Our washing machine predicts the fuel economy of cars. Bear with me.

How does the model learn?

Fitting a model means choosing its parameters (for a linear regression, the coefficients and the intercept) so that the predictions on the training data are as good as possible, where “good” is a measure picked before the training starts.

For LinearRegression, “good” means the smallest sum of squared errors on the training data (ordinary least squares). Your statistics course probably derived the formula: take the derivative, set it to 0, solve the equations. Scikit-learn skips the derivation and hands the columns to SciPy’s least-squares solver (scipy.linalg.lstsq), which returns the coefficients in one step. There’s no training loop, no learning rate, and nothing to babysit while it converges.

If you remember gradient descent from your course, it’s the other way to reach the answer: start somewhere and keep stepping downhill. (Most courses teach gradient descent on a linear regression first, which leaves plenty of people convinced that fit() runs a loop here too. That’s why I’m telling you it doesn’t.) Scikit-learn has it in SGDRegressor, for data too big to solve in one go.

What the fit guarantees

The solution of least squares satisfies this equation, where \(X\) holds the columns plus a column of ones for the intercept:

\[X^\top (y - X\beta) = 0\]

\(y - X\beta\) are the errors on the training data. Multiply them by any column, add them up, and you get 0. For the column of ones, that means the errors add up to 0. For every other column, it means the errors have zero correlation with it. Both hold for every least-squares fit with an intercept, no matter how bad the model is.

X = X_train[year_blind]
blind = LinearRegression().fit(X, y_train)
training_errors = y_train - blind.predict(X)
training_errors.mean(), X.corrwith(training_errors).abs().max()
(np.float64(3.7031543231796754e-16), np.float64(3.5496677343381666e-15))

Both are 0, give or take floating-point rounding. It’s also why the reciprocal model in Scoring a transformed target missed its own training cars by 0.25 mpg on average. Its errors added up to 0 in gallons per 100 miles, and we scored them in mpg.

Rule 17: A least-squares fit with an intercept makes its training errors average out to 0, in the units it was fitted in, however wrong the model is. Measure the bias only on rows the model didn’t learn from.

When the answer isn’t unique

If the same column appears twice, any pair of coefficients with the same sum gives the same predictions, and the data has no way to pick one. Scikit-learn doesn’t complain. It returns the pair with the smallest coefficients, so a duplicated weight column gets half of the slope twice. The dummy variable trap is the same problem: three region columns that always add up to the column of ones.

Exact copies are rare. Near copies are all over our data, as the correlation matrix showed. To see what they do, I copied the weight, moved every copied value by about a pound in a random direction, and fitted the model once per fold.

rng = np.random.default_rng(0)
# Every weight again, off by about a pound
almost_twice = X_train[['weight']].assign(weight_again=X_train['weight'] + rng.normal(0, 1, len(X_train)))

coefficients = {}
for (train_rows, _), year in zip(folds, range(75, 80)):
    fitted = LinearRegression().fit(almost_twice.iloc[train_rows], y_train.iloc[train_rows])
    coefficients[year] = pd.Series(fitted.coef_, index=almost_twice.columns)

pd.DataFrame(coefficients).to_markdown()
  75 76 77 78 79
weight -0.452994 -0.234364 -0.104216 0.0918197 -0.0375866
weight_again 0.447359 0.228764 0.0984565 -0.0977508 0.0313247

The real slope is about −0.0064 mpg per pound. In 1975, the model multiplies weight by −0.45 and its copy by +0.45. In 1978, both flip their signs. Each pair still adds up to roughly the real slope, so the predictions hardly change:

pd.DataFrame({
    'weight once': fold_rmse(LinearRegression(), X_train[['weight']]),
    'weight almost twice': fold_rmse(LinearRegression(), almost_twice),
}).to_markdown()
validation year weight once weight almost twice
75 2.71615 2.88009
76 2.73505 2.79396
77 3.45999 3.50943
78 4.52069 4.54256
79 5.0282 5.02994

If you only watched the fold scores, you would never notice that the coefficients are garbage.

Reading the coefficients

Interpretability is how well a person can explain why a model made a prediction, in terms they can check against what they know about the problem.

All else equal

Here are the coefficients of each column fitted alone, next to those of the year-blind model with all six:

pd.DataFrame({
    'alone': {column: LinearRegression().fit(X_train[[column]], y_train).coef_[0] for column in year_blind},
    'together': pd.Series(blind.coef_, index=year_blind),
}).to_markdown()
  alone together
cylinders -2.99787 -0.224772
displacement -0.0500235 0.000681666
horsepower -0.12848 -0.04044
weight -0.00642702 -0.00441617
acceleration 1.00368 -0.149432
origin 4.95492 0.623622

Alone, weight takes the credit for everything that comes with weight: more cylinders, a bigger engine, more horsepower. Together, the other columns take their share, and weight keeps what’s left. Both numbers are correct answers to different questions.

Acceleration is the time from 0 mph to 60 mph in seconds, so a bigger number means a slower car. Alone, each extra second adds 1.00 mpg, because the slow cars are the small, weak ones. Together, at the same weight and horsepower, each extra second takes 0.15 mpg away. Why would a slower car with the same weight and the same horsepower burn more fuel? Gearing, maybe, or automatic transmissions. Neither is in the data, so neither the model nor I can tell you.

“Every other column stays where it was” is also a bit of a fairy tale here. Adding 1,000 lbs to a car without changing its engine or its horsepower describes a car nobody built, and the coefficient answers the question anyway.

What the residuals tell you

Statistics courses list the assumptions behind linear regression: linearity (the relationship is a straight line), independence (the errors don’t depend on each other), equal variance (their spread is the same everywhere), and normality (they follow a normal distribution). You check them by plotting the errors. Here are the year-blind model’s errors on the folds, against what it predicted.

fold_predictions = (y_train[baseline_residuals.index] - baseline_residuals).rename('predicted MPG')

plt.scatter(fold_predictions, baseline_residuals, alpha=0.6)
plt.axhline(0, color='red')
plt.title('Year-blind model: errors on the 1975-1979 folds', loc='left')
plt.xlabel('predicted MPG')
plt.ylabel('actual - predicted [mpg]')
plt.show()
Errors of the year-blind model on the 1975-1979 folds against the predicted MPG
Errors of the year-blind model on the 1975-1979 folds against the predicted MPG

If the assumptions held, you would see a flat band of noise around the red line. Instead, the errors curve up at both ends, three in four of them sit above zero, and the band gets wider toward the right. The curve is the linearity assumption failing. The lean above zero comes from the model year, which this model doesn’t have.

The widening band needs a closer look:

# From the lowest predicted MPG to the highest
QUARTERS = ['lowest 25%', '25-50%', '50-75%', 'highest 25%']
quartiles = pd.qcut(fold_predictions, 4, labels=QUARTERS)
baseline_residuals.groupby(quartiles, observed=True).agg(['mean', 'std', 'count']).to_markdown()
predicted MPG mean std count
lowest 25% 1.63054 1.9241 40
25-50% 0.429955 2.4048 39
50-75% 1.7642 3.05726 39
highest 25% 4.28123 4.03421 39

The standard deviation of the errors doubles from the gas-guzzlers to the economical cars. Statisticians call unequal spread heteroscedasticity, a word invented to be misspelled. (I knew the problem long before I knew its name. I just googled the term and will forget it by the end of the next paragraph. Skip the name. Just remember that one RMSE hides a 2-mpg spread on some cars and a 4-mpg spread on others.)

Which assumptions matter for prediction?

Linearity, and whatever the lean comes from. Those two put predictions in the wrong place. Independence, equal variance, and normal errors matter for confidence intervals, prediction intervals, and p-values, the tables statistics courses spend weeks on. LinearRegression doesn’t compute any of them (statsmodels does), and this book doesn’t use them. If all you need is predictions, skip the normality test and spend the afternoon on the curve and the lean.

The lean needs information the model doesn’t have, which is Rule 16, and no change to the model will fix it. The curve needs a model that can bend, and that’s what we build in the next chapter, along with the first model in this book that can fit the training data too well.

Subscribe to the newsletter

I write about building AI systems that survive contact with real users. Subscribe to the newsletter. Newsletter: AI systems that survive real users