←Back to the bookChapter 99 · Logistic Regression
▶ Open in Colab⬇ Download notebookView on GitHub
Chapter 99 · Solutions

Chapter 99 · Solutions

Five challenges, each verified in code.

Solutions to the five challenges from Chapter 99, with statsmodels and scikit-learn.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy import stats
import seaborn as sns   # seaborn = high-level statistical plots (heatmaps, regplots, pairplots)
import statsmodels.api as sm
from statsmodels.formula.api import ols
from statsmodels.stats.outliers_influence import variance_inflation_factor
from statsmodels.nonparametric.smoothers_lowess import lowess
from sklearn.linear_model import LinearRegression, Ridge, Lasso, LogisticRegression
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import r2_score, mean_squared_error
ORG="#ea580c"; DEEP="#c2410c"; LIGHT="#fdba74"; INK="#1a2138"; GRID="#e6e9f2"; GREEN="#059669"; RED="#ef4444"; AMBER="#d97706"; BLUE="#2563eb"; PUR="#9333ea"; GREY="#94a3b8"; SLATE="#475569"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
   "axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
   "axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
sns.set_style("whitegrid")
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
In [2]:
import warnings; warnings.filterwarnings('ignore')
import statsmodels.formula.api as smf
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (roc_auc_score, roc_curve, confusion_matrix, classification_report, recall_score, RocCurveDisplay)
try:
    loans = pd.read_excel('../../data/logistic-regression--loans.xlsx', sheet_name='Loans')
except FileNotFoundError:
    loans = pd.read_excel(BASE + 'logistic-regression--loans.xlsx', sheet_name='Loans')
CHALLENGE 1
Fit and read an odds ratio
credit-score OR per 50 points.
In [3]:
m = smf.logit('default ~ credit_score + dti', loans).fit(disp=0)
orr = np.exp(m.params['credit_score']*50)
print(f'Each +50 credit points multiplies the odds of default by {orr:.2f} (a {100*(1-orr):.0f}% reduction).')
Each +50 credit points multiplies the odds of default by 0.40 (a 60% reduction).
CHALLENGE 2
Confusion matrix at 0.5
accuracy, precision, recall.
In [4]:
p = m.predict(loans); yhat = (p>=0.5).astype(int)
print(confusion_matrix(loans.default, yhat))
print(classification_report(loans.default, yhat, digits=2, target_names=['repaid','default']))
[[267  30]
 [ 55  68]]
              precision    recall  f1-score   support

      repaid       0.83      0.90      0.86       297
     default       0.69      0.55      0.62       123

    accuracy                           0.80       420
   macro avg       0.76      0.73      0.74       420
weighted avg       0.79      0.80      0.79       420

CHALLENGE 3
ROC, AUC, better threshold
threshold for >=75% recall.
In [5]:
auc = roc_auc_score(loans.default, p); print(f'AUC = {auc:.3f}')
fpr,tpr,thr = roc_curve(loans.default, p)
i = np.where(tpr>=0.75)[0][0]
print(f'A threshold of ~{thr[i]:.2f} catches {tpr[i]:.0%} of defaults (FPR {fpr[i]:.0%}).')
AUC = 0.819
A threshold of ~0.31 catches 76% of defaults (FPR 27%).
CHALLENGE 4
The accuracy paradox
baseline vs class-weighted recall.
In [6]:
print(f'predict-all-repaid accuracy = {(loans.default==0).mean():.2f}')
X = loans[['credit_score','dti','loan_amount']]; y = loans.default
bal = LogisticRegression(max_iter=1000, class_weight="balanced").fit(X,y)
print(f'balanced recall on defaults = {recall_score(y, bal.predict(X)):.2f}')
predict-all-repaid accuracy = 0.71
balanced recall on defaults = 0.78
CHALLENGE 5
Break it: perfect separation
statsmodels fails, penalized logistic is fine.
In [7]:
sep = loans.copy(); sep['leak'] = sep['default']*10 + np.random.default_rng(1).normal(0,0.01,len(sep))
try:
    fit = smf.logit('default ~ leak', sep).fit(disp=0, maxiter=100)
    # statsmodels 0.15 returns a fit that never converged, with an exploded standard error
    print(f"MLE fails on separation: converged = {fit.mle_retvals['converged']}, standard error on leak = {fit.bse['leak']:,.0f}")
except Exception as e:   # statsmodels 0.14 and earlier stop with an error instead
    print('MLE fails on separation:', type(e).__name__)
reg = LogisticRegression(max_iter=1000).fit(sep[['leak']], sep.default)
print(f'regularized coefficient stays finite: {reg.coef_[0][0]:.2f}')
MLE fails on separation: converged = False, standard error on leak = 30,425
regularized coefficient stays finite: 1.43

Statistics, Data Science and AI: A Visual Handbook · © 2026 John Fisher
←Back to the bookChapter 99 · Logistic Regression