Logistic Regression
The sigmoid, log-odds, odds ratios, and regularisation you did not ask for
Linear regression predicts a number on an unbounded scale. A probability lives between 0 and 1. Logistic regression is what you get when you insist on that constraint, and it remains the model to beat on tabular data.
The sigmoid
def sigmoid(z):
return 1 / (1 + np.exp(-z))
for z in [-6, -2, -1, 0, 1, 2, 6]:
print('z = %3d -> probability %.4f' % (z, sigmoid(z)))
z = -2 -> probability 0.1192
z = -1 -> probability 0.2689
z = 0 -> probability 0.5000
z = 1 -> probability 0.7311
z = 2 -> probability 0.8808
z = 6 -> probability 0.9975
Any real number in, a probability out. Zero maps to 0.5, and the curve saturates at both ends, which is why a very confident model needs an enormous change in z to become slightly more confident.
z = X @ w + b exactly as linear regression does, then passes z through the sigmoid to get a probability. It is fitted by minimising log loss, not squared error. Despite the name it is a classifier.
Log-odds, which is the scale the coefficients live on
def sigmoid(z):
return 1 / (1 + np.exp(-z))
print('%10s %10s %12s' % ('probability', 'odds', 'log-odds (z)'))
for prob in [0.1, 0.25, 0.5, 0.75, 0.9]:
odds = prob / (1 - prob)
print('%10.2f %10.3f %12.4f' % (prob, odds, np.log(odds)))
0.10 0.111 -2.1972
0.25 0.333 -1.0986
0.50 1.000 0.0000
0.75 3.000 1.0986
0.90 9.000 2.1972
A coefficient is a change in log-odds
That is why they are hard to read directly. Exponentiate one and you get an odds ratio: a coefficient of 0.7 means exp(0.7) = 2.01, so a one-unit increase roughly doubles the odds. Doubling the odds is not doubling the probability, from 0.1 it goes to 0.18, from 0.5 it goes to 0.67.
Fit it on the churn problem
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')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor():
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, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, log_loss, accuracy_score
model = Pipeline([('prep', preprocessor()),
('clf', LogisticRegression(max_iter=1000,
random_state=42))]).fit(X_tr, y_tr)
proba = model.predict_proba(X_te)[:, 1]
print('accuracy %.4f' % accuracy_score(y_te, model.predict(X_te)))
print('ROC AUC %.4f' % roc_auc_score(y_te, proba))
print('log loss %.4f' % log_loss(y_te, proba))
print('\npredicted probabilities: min %.3f, median %.3f, max %.3f'
% (proba.min(), np.median(proba), proba.max()))
ROC AUC 0.8174
log loss 0.4451
predicted probabilities: min 0.000, median 0.232, max 0.829
Read the coefficients as odds ratios
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')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor():
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, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
from sklearn.linear_model import LogisticRegression
import numpy as np
import pandas as pd
model = Pipeline([('prep', preprocessor()),
('clf', LogisticRegression(max_iter=1000,
random_state=42))]).fit(X_tr, y_tr)
names = model.named_steps['prep'].get_feature_names_out()
coef = model.named_steps['clf'].coef_[0]
table = pd.DataFrame({'coefficient': coef, 'odds_ratio': np.exp(coef)},
index=names).sort_values('coefficient')
print(table.round(3).to_string())
cat__contract_Two Year -1.454 0.234
num__tenure_months -0.978 0.376
cat__payment_method_Credit card -0.205 0.815
cat__payment_method_Bank transfer -0.174 0.841
cat__internet_service_No internet -0.163 0.850
cat__has_dependents_Yes -0.102 0.903
cat__payment_method_Mailed check -0.088 0.916
cat__internet_service_DSL -0.060 0.942
cat__has_dependents_No 0.087 1.091
cat__contract_One Year 0.128 1.137
cat__internet_service_Fibre optic 0.207 1.230
num__support_calls 0.279 1.322
num__monthly_charges 0.436 1.547
cat__payment_method_Electronic check 0.451 1.570
cat__contract_Month-To-Month 1.310 3.706
An odds ratio above 1 pushes toward churn, below 1 pushes away. Month-to-month roughly triples the odds against the average; a two-year contract cuts them to about a quarter. Compare that with the generator in week 1: it used +1.55 and −0.85 on exactly those two.
Odds ratios on scaled features are per standard deviation
The numeric columns went through StandardScaler, so their coefficients describe a one standard deviation change, not one month or one pound. To talk to a business audience in real units, divide the coefficient by the scaler's scale_ for that column, or fit an unscaled model purely for explanation.
Regularisation is on by default
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')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor():
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, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
import numpy as np
print('%10s %12s %14s' % ('C', 'test AUC', 'sum |coef|'))
for C_val in [0.001, 0.01, 0.1, 1.0, 100.0]:
m = Pipeline([('prep', preprocessor()),
('clf', LogisticRegression(C=C_val, max_iter=2000,
random_state=42))]).fit(X_tr, y_tr)
auc = roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])
print('%10s %12.4f %14.3f'
% (C_val, auc, np.abs(m.named_steps['clf'].coef_).sum()))
0.001 0.8205 0.885
0.01 0.8230 3.114
0.1 0.8198 5.147
1.0 0.8174 6.122
100.0 0.8169 8.108
C is the inverse of alpha
Ridge takes alpha, where larger means more penalty. LogisticRegression takes C, where smaller means more penalty. The default of C=1.0 means you are already regularising whether you meant to or not, which surprises people comparing against an unpenalised implementation elsewhere.
Day 1 takeaway
Logistic regression is linear regression pushed through a sigmoid and fitted on log loss. Coefficients are changes in log-odds; exponentiate them for odds ratios, and remember they are per standard deviation when the features were scaled. Regularisation is on by default, controlled byC, which runs the opposite way to alpha.