Linear and logistic regression are the models to try first and the ones to beat. They fit in seconds, their coefficients are readable, and a problem where they do badly tells you something before you have spent a day on anything larger.
Linear regression
The model is a weighted sum: prediction = intercept + w1 * x1 + w2 * x2 + ..., with the weights chosen to minimise the squared error.
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(3)
n = 2000
area = rng.normal(95, 25, n).clip(30)
bedrooms = np.clip((area / 30 + rng.normal(0, 0.5, n)).round(), 1, 6)
age = rng.gamma(2, 12, n).clip(0, 120)
# A genuinely linear relationship, plus noise.
price = (42_000 + 2_150 * area + 9_500 * bedrooms - 380 * age
+ rng.normal(0, 22_000, n))
frame = pd.DataFrame({'area': area, 'bedrooms': bedrooms, 'age': age, 'price': price})
X_tr, X_te, y_tr, y_te = train_test_split(
frame[['area', 'bedrooms', 'age']], frame['price'], test_size=0.3, random_state=0)
model = LinearRegression().fit(X_tr, y_tr)
predicted = model.predict(X_te)
print(pd.DataFrame({'feature': X_tr.columns, 'coefficient': model.coef_.round(0),
'true': [2150, 9500, -380]}).to_string(index=False))
print(f'intercept {model.intercept_:,.0f} (true 42,000)')
print()
print(f'R2 {r2_score(y_te, predicted):.3f}')
print(f'MAE {mean_absolute_error(y_te, predicted):,.0f}')
Open this example in the editor and change it. Nothing you type is saved.
Each coefficient reads as: holding the other features constant, one more unit of this feature changes the prediction by this much. That sentence is the whole reason to start here - no other model family gives it to you for free.
R2 is the share of the variance the model accounts for. It is comparable within one dataset and meaningless across datasets, because it depends on how much variance there was to begin with.
The assumptions, and what breaks them
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
rng = np.random.default_rng(7)
n = 1500
x = rng.uniform(1, 20, n)
shapes = {
'linear': 3 * x + rng.normal(0, 3, n),
'monotonic curve': 0.4 * x ** 2 + rng.normal(0, 3, n),
'u-shaped': 0.5 * (x - 10) ** 2 + rng.normal(0, 3, n),
'periodic': 20 * np.sin(x) + rng.normal(0, 3, n),
'step at the middle': np.where(x > 10, 40, 10) + rng.normal(0, 3, n),
}
for name, y in shapes.items():
model = LinearRegression().fit(x.reshape(-1, 1), y)
print(f'{name:<20} R2 {r2_score(y, model.predict(x.reshape(-1, 1))):.3f}')
print()
print('A monotonic curve is approximated passably by a straight line;')
print('a U-shape and a periodic signal are not approximated at all,')
print('because no single slope describes them. The fix is usually a')
print('transformed feature, not a different algorithm.')
Open this example in the editor and change it. Nothing you type is saved.
The fix for a curve is often a feature, not a model:
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
rng = np.random.default_rng(7)
n = 1500
x = rng.uniform(1, 20, n)
y = 0.5 * (x - 10) ** 2 + rng.normal(0, 3, n) # the U-shape
plain = LinearRegression().fit(x.reshape(-1, 1), y)
with_square = LinearRegression().fit(np.column_stack([x, x ** 2]), y)
print(f'x only R2 {r2_score(y, plain.predict(x.reshape(-1, 1))):.3f}')
print(f'x and x**2 R2 {r2_score(y, with_square.predict(np.column_stack([x, x ** 2]))):.3f}')
print()
print('"Linear" means linear in the PARAMETERS, not in the inputs.')
print('A squared term, a log, an interaction - all still linear models.')
Open this example in the editor and change it. Nothing you type is saved.
Two more assumptions worth checking:
Constant error variance. If the errors grow with the prediction, the model is overconfident at the top end. The usual fix is to model log(y) instead of y.
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_percentage_error
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(11)
n = 2000
x = rng.uniform(1, 10, n)
# Error proportional to the level - very common with money.
y = 1000 * np.exp(0.4 * x) * rng.lognormal(0, 0.25, n)
X_tr, X_te, y_tr, y_te = train_test_split(x.reshape(-1, 1), y, test_size=0.3, random_state=0)
direct = LinearRegression().fit(X_tr, y_tr)
logged = LinearRegression().fit(X_tr, np.log(y_tr))
print(f'on y: MAPE {mean_absolute_percentage_error(y_te, direct.predict(X_te)):.1%}')
print(f'on log(y): MAPE '
f'{mean_absolute_percentage_error(y_te, np.exp(logged.predict(X_te))):.1%}')
Open this example in the editor and change it. Nothing you type is saved.
No extreme multicollinearity. Two features that carry the same information make the coefficients unstable, even though the predictions stay fine.
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(13)
n = 500
a = rng.normal(0, 1, n)
b = a + rng.normal(0, 0.02, n) # almost identical to a
y = 3 * a + rng.normal(0, 0.5, n)
for seed in range(3):
sample = rng.choice(n, n, replace=True)
model = LinearRegression().fit(np.column_stack([a[sample], b[sample]]), y[sample])
print(f'sample {seed}: coefficients {model.coef_.round(2)} '
f'(their sum {model.coef_.sum():.2f})')
print()
print('The individual coefficients swing wildly between samples; their')
print('SUM is stable. Do not interpret a coefficient when two features')
print('are nearly the same thing.')
Open this example in the editor and change it. Nothing you type is saved.
Logistic regression
For a yes/no label, the same weighted sum is passed through a function that squashes it into a probability.
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
rng = np.random.default_rng(17)
n = 3000
tenure = rng.integers(1, 60, n)
tickets = rng.poisson(1.6, n)
spend = rng.gamma(3, 25, n)
logit = -0.5 - 0.045 * tenure + 0.42 * tickets - 0.011 * spend
probability = 1 / (1 + np.exp(-logit))
churned = (rng.random(n) < probability).astype(int)
X = np.column_stack([tenure, tickets, spend])
X_tr, X_te, y_tr, y_te = train_test_split(X, churned, test_size=0.3,
random_state=0, stratify=churned)
model = Pipeline([('scale', StandardScaler()),
('model', LogisticRegression(max_iter=1000))]).fit(X_tr, y_tr)
probabilities = model.predict_proba(X_te)[:, 1]
print(f'positive rate {churned.mean():.1%}')
print(f'accuracy {accuracy_score(y_te, model.predict(X_te)):.3f}')
print(f'ROC AUC {roc_auc_score(y_te, probabilities):.3f}')
print()
print('first five probabilities:', probabilities[:5].round(3))
Open this example in the editor and change it. Nothing you type is saved.
The output is a probability, which is more useful than the class. predict() applies a 0.5 threshold, which is a choice - and rarely the right one, as the evaluation lesson shows.
Coefficients in logistic regression are log-odds, and exponentiating them gives an odds ratio:
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
rng = np.random.default_rng(19)
n = 4000
tenure = rng.integers(1, 60, n)
tickets = rng.poisson(1.6, n)
spend = rng.gamma(3, 25, n)
logit = -0.5 - 0.045 * tenure + 0.42 * tickets - 0.011 * spend
churned = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
# Unscaled, so the coefficients are in the features' own units.
model = LogisticRegression(max_iter=2000).fit(
np.column_stack([tenure, tickets, spend]), churned)
table = pd.DataFrame({
'feature': ['tenure_months', 'tickets_90d', 'monthly_spend'],
'coefficient': model.coef_[0].round(4),
'true': [-0.045, 0.42, -0.011],
})
table['odds_ratio'] = np.exp(model.coef_[0]).round(3)
print(table.to_string(index=False))
print()
print('Read the third row as: each extra ticket multiplies the odds of')
print(f'churn by {np.exp(model.coef_[0][1]):.2f} - about a '
f'{(np.exp(model.coef_[0][1]) - 1) * 100:.0f}% increase in the odds,')
print('holding tenure and spend constant.')
Open this example in the editor and change it. Nothing you type is saved.
Why start here
import numpy as np
import pandas as pd
import time
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
rng = np.random.default_rng(23)
n = 4000
X = rng.normal(size=(n, 8))
# A LINEAR relationship, which is the common case in tabular business data.
logit = 1.2 * X[:, 0] - 0.8 * X[:, 1] + 0.5 * X[:, 2] + rng.normal(0, 0.8, n)
y = (logit > 0).astype(int)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
models = {
'logistic regression': Pipeline([('s', StandardScaler()),
('m', LogisticRegression(max_iter=1000))]),
'random forest': RandomForestClassifier(n_estimators=300, random_state=0),
'gradient boosting': GradientBoostingClassifier(random_state=0),
}
for name, model in models.items():
start = time.perf_counter()
model.fit(X_tr, y_tr)
seconds = time.perf_counter() - start
auc = roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])
print(f'{name:<22} AUC {auc:.3f} fitted in {seconds * 1000:7.0f}ms')
print()
print('On a linear problem the simplest model wins and is far faster.')
print('That is not a coincidence - it is the right inductive bias.')
Open this example in the editor and change it. Nothing you type is saved.
Four practical reasons it earns the first attempt:
It is fast, so you learn whether the problem is learnable in minutes. It is readable, so a coefficient with the wrong sign reveals a data problem immediately. It extrapolates, unlike tree models, which matters when inputs move outside the training range. It is a baseline that is hard to argue with. If the complicated model does not beat it, ship the simple one.
Regularisation: ridge and lasso
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
rng = np.random.default_rng(29)
n, p = 300, 60
X = rng.normal(size=(n, p))
true_weights = np.zeros(p)
true_weights[:5] = [3.0, -2.0, 1.5, -1.0, 0.8] # only 5 of 60 matter
y = X @ true_weights + rng.normal(0, 1.0, n)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0)
rows = []
for name, estimator in [('linear', LinearRegression()),
('ridge (a=1)', Ridge(alpha=1.0)),
('ridge (a=10)', Ridge(alpha=10.0)),
('lasso (a=0.1)', Lasso(alpha=0.1, max_iter=5000)),
('lasso (a=0.3)', Lasso(alpha=0.3, max_iter=5000))]:
model = Pipeline([('scale', StandardScaler()), ('model', estimator)]).fit(X_tr, y_tr)
coefficients = model.named_steps['model'].coef_
rows.append({
'model': name,
'test MAE': round(mean_absolute_error(y_te, model.predict(X_te)), 3),
'nonzero coefs': int((np.abs(coefficients) > 1e-6).sum()),
'found the 5': int((np.abs(coefficients[:5]) > 1e-6).sum()),
})
print(pd.DataFrame(rows).to_string(index=False))
print()
print('With 60 features and 210 training rows, plain linear regression')
print('fits the noise. Lasso sets most coefficients to exactly zero and')
print('keeps the five that matter.')
Open this example in the editor and change it. Nothing you type is saved.
Ridge shrinks every coefficient towards zero without reaching it, which stabilises them when features are correlated. Lasso drives some to exactly zero, which performs feature selection. ElasticNet does both.
Both require scaled features - the penalty is on the coefficient size, so a feature measured in pounds is penalised a thousand times more than the same feature in thousands of pounds.
What they cannot do
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(31)
n = 3000
X = rng.normal(size=(n, 2))
# The label depends on an INTERACTION: positive when the signs agree.
y = ((X[:, 0] > 0) == (X[:, 1] > 0)).astype(int)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0)
plain = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
print(f'logistic, raw features: AUC '
f'{roc_auc_score(y_te, plain.predict_proba(X_te)[:, 1]):.3f}')
# Give it the interaction explicitly and it recovers.
with_interaction = LogisticRegression(max_iter=1000).fit(
np.column_stack([X_tr, X_tr[:, 0] * X_tr[:, 1]]), y_tr)
print(f'logistic, with x0 * x1: AUC '
f'{roc_auc_score(y_te, with_interaction.predict_proba(np.column_stack([X_te, X_te[:, 0] * X_te[:, 1]]))[:, 1]):.3f}')
forest = RandomForestClassifier(n_estimators=200, random_state=0).fit(X_tr, y_tr)
print(f'random forest: AUC '
f'{roc_auc_score(y_te, forest.predict_proba(X_te)[:, 1]):.3f}')
print()
print('A linear model cannot find an interaction unless you give it')
print('one. A tree finds it automatically - which is the main reason')
print('trees are the next thing to try.')
Open this example in the editor and change it. Nothing you type is saved.
The three limitations, in order of how often they bite:
- No interactions unless you write them.
x1 * x2must be an explicit feature. - No non-linearity unless you transform.
log(x)andx**2must be explicit. - Sensitive to outliers, because squared error punishes a single large residual enormously.
HuberRegressoris the robust alternative.
When it goes wrong
| Symptom | Cause |
|---|---|
| A coefficient has the wrong sign | Collinearity, or a confounder in the features |
| Coefficients swing between samples | Two features carry the same information |
| Good R2, obviously curved residuals | Linear fit on a non-linear relationship |
| The solver warns about convergence | Unscaled features; add a StandardScaler |
| Lasso zeroed a feature you believe in | It is correlated with another one it kept |
| Errors grow with the prediction | Model log(y) instead of y |
| The model misses an obvious interaction | Add the product term, or use a tree |
| Regularisation hurt badly | Features are unscaled; the penalty is uneven |
A check you can run
Fit a linear or logistic regression before the model you intended to build, on the same split, and write both scores down.
If the gap is small, ship the simple one: it is faster, explainable, and it extrapolates. If the gap is large, you have learned something specific - the relationship is non-linear or has interactions - and that tells you which features to engineer rather than which library to install.