Linear Regression on a Real Target
Fitting, reading the score against the right baseline, and what residuals reveal
Week 3 fitted a line by hand. This week fits real regressions, and the first job is choosing a target worth predicting.
The problem
Predict monthly_charges from everything else. It is a genuine business question, what should this customer be paying, given who they are, and unlike week 3's toy version it has real signal in it.
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')
df = df.dropna(subset=['monthly_charges'])
NUM = ['tenure_months', 'support_calls']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'monthly_charges'
print('rows %d' % len(df))
print('target: mean %.2f std %.2f min %.2f max %.2f'
% (df[TARGET].mean(), df[TARGET].std(), df[TARGET].min(), df[TARGET].max()))
print('\nby internet service:')
print(df.groupby('internet_service')[TARGET].agg(['size', 'mean', 'std']).round(2))
target: mean 59.41 std 23.47 min 15.00 max 105.96
by internet service:
size mean std
internet_service
DSL 965 55.19 8.99
Fibre optic 1282 79.56 9.18
No internet 588 22.38 7.34
Three tight clusters. Internet service almost determines the price, which is a promising sign: there is real structure for a model to find.
Fit it
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')
df = df.dropna(subset=['monthly_charges'])
NUM = ['tenure_months', 'support_calls']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'monthly_charges'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(num=NUM, cat=CAT):
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), cat),
])
from sklearn.model_selection import train_test_split
X = df[NUM + CAT]
y = df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=42)
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, root_mean_squared_error, r2_score
model = Pipeline([('prep', preprocessor()),
('reg', LinearRegression())]).fit(X_tr, y_tr)
pred = model.predict(X_te)
print('MAE %6.3f' % mean_absolute_error(y_te, pred))
print('RMSE %6.3f' % root_mean_squared_error(y_te, pred))
print('R2 %6.4f' % r2_score(y_te, pred))
print('\nstd of the target was %.2f, so predicting the mean'
' would give RMSE about that.' % y_te.std())
RMSE 9.033
R2 0.8498
std of the target was 23.32, so predicting the mean would give RMSE about that.
Compare RMSE against the target's standard deviation
Predicting the mean for everybody gives an RMSE equal to the standard deviation, about 23.3 here. Our model gets just over 9. That ratio is the honest way to read a regression score, and it is what R² formalises: 0.85 means the model removed 85 percent of the variance the mean-only model left behind.
Residuals are where the truth is
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')
df = df.dropna(subset=['monthly_charges'])
NUM = ['tenure_months', 'support_calls']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'monthly_charges'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(num=NUM, cat=CAT):
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), cat),
])
from sklearn.model_selection import train_test_split
X = df[NUM + CAT]
y = df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=42)
from sklearn.linear_model import LinearRegression
model = Pipeline([('prep', preprocessor()),
('reg', LinearRegression())]).fit(X_tr, y_tr)
resid = y_te - model.predict(X_te)
print('mean %8.4f <- should be near zero' % resid.mean())
print('median %8.4f' % resid.median())
print('std %8.4f' % resid.std())
print('skew %8.4f <- should be near zero' % resid.skew())
print('\nlargest under-predictions:')
print(resid.nlargest(3).round(2).to_string())
print('largest over-predictions:')
print(resid.nsmallest(3).round(2).to_string())
median -0.6659
std 9.0292
skew 0.0188 <- should be near zero
largest under-predictions:
132 26.92
1074 25.72
2735 25.56
largest over-predictions:
3076 -30.66
767 -27.13
2637 -26.33
The four assumptions, and which ones matter
| Assumption | What breaks if violated | How much you should care |
|---|---|---|
| Linearity | Systematic curved pattern in residuals | A lot. The model is simply wrong |
| Independent errors | Standard errors understated | A lot for time series, rarely otherwise |
| Constant variance | Predictions unreliable at the extremes | Moderate, affects intervals more than point estimates |
| Normal errors | Confidence intervals are off | Least of the four; irrelevant for pure prediction |
These are assumptions about residuals, not about your columns
A common misreading: people check whether their features are normally distributed. Linear regression makes no such requirement. The normality assumption is about the errors, and it only matters when you want confidence intervals on the coefficients. For prediction you can ignore it.
Check linearity by plotting residuals against predictions
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')
df = df.dropna(subset=['monthly_charges'])
NUM = ['tenure_months', 'support_calls']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'monthly_charges'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(num=NUM, cat=CAT):
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), cat),
])
from sklearn.model_selection import train_test_split
X = df[NUM + CAT]
y = df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=42)
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
model = Pipeline([('prep', preprocessor()),
('reg', LinearRegression())]).fit(X_tr, y_tr)
pred = model.predict(X_te)
resid = y_te - pred
fig, ax = plt.subplots(figsize=(7, 4))
ax.scatter(pred, resid, s=8, alpha=0.4, color='#2563eb')
ax.axhline(0, color='#dc2626', linewidth=1)
ax.set_xlabel('Predicted')
ax.set_ylabel('Residual')
fig.tight_layout()
fig.savefig('residuals.png', dpi=120)
plt.close(fig)
# The same check, numerically: is the mean residual flat across the range?
bins = pd.qcut(pred, 5, labels=False)
print(pd.DataFrame({'pred_bin': bins, 'resid': resid.to_numpy()})
.groupby('pred_bin')['resid'].agg(['size', 'mean', 'std']).round(3))
pred_bin
0 142 0.577 7.503
1 142 0.261 9.132
2 141 -0.570 9.695
3 142 -0.045 9.648
4 142 -2.397 8.816
The bin means sit between +0.58 and −2.40 against a residual standard deviation of 9, so there is no strong trend, a slight tendency to over-predict the most expensive customers, and little else. That is roughly what a correctly specified linear model looks like. A clear U-shape here would mean the relationship is curved and you need the polynomial features from day 3.