CSCI 3151: Foundations of Machine Learning
2025-11-23
By M05, we assume:
NumPy, pandas, matplotlib, and basic scikit-learn regression APIs.We do not assume you’ve seen logistic regression derivations before.
By the end of this module, you should be able to:
From M04:
☺️ This works well when:
Now \(y \in \{0,1\}\), e.g.,
We want:
Imagine an airline monitoring jet engines. For each engine, we log telemetry from the first few minutes of a flight:
vibration_rms (RMS vibration, in g)oil_temp_delta (oil temperature above nominal, in °C)🤔 Decision:
Flag engines that are likely to fail soon so maintenance can intervene.
👀 We’d like:
Try to treat \(y\in\{0,1\}\) as a continuous target and use linear regression:
Fit \[ \hat{y} = w_1^\top x + w_0 \] by minimizing squared loss.
Interpret \(\hat{y}\) as a “probability” and clip to [0,1] if needed.
Threshold at 0.5 to predict class 1 vs 0.
Problems:
👀 Notice:
Logistic regression fixes this by modelling the probability of class 1 directly.
Compute a linear score: \[ z = \theta^\top x = w_1^\top x + w_0. \]
Squash it with the sigmoid: \[ \sigma(z) = \frac{1}{1 + e^{-z}}. \]
Interpret: \[ \hat{p}(y=1 \mid x) = \sigma( \theta^\top x). \]
This guarantees \(\hat{p} \in (0,1)\) and fits naturally with a Bernoulli likelihood.
Let \(y \in \{0,1\}\) and \(\hat{p} = \hat{p}(y=1 \mid x)\).
Define the logistic loss: \[ \ell_{ \text{log}}(\hat{p}, y) = -\big(y \log \hat{p} + (1-y)\log(1-\hat{p})\big). \]
Intuition:
For a dataset, empirical risk: \[ \hat{L}_n( \theta) = \frac{1}{n}\sum_{i=1}^n \ell_{\text{log}}\big(\hat{p}^{(i)}, y^{(i)}\big). \]
Training logistic regression = minimize this average loss.
Three models make identical hard predictions but different probability predictions:
import numpy as np
from sklearn.metrics import log_loss
y_true = np.array([0, 0, 1, 1])
p_A = np.array([0.49, 0.49, 0.51, 0.51])
p_B = np.array([0.1, 0.4, 0.6, 0.9])
p_C = np.array([0.01, 0.01, 0.99, 0.99])
for name, p in [("A", p_A), ("B", p_B), ("C", p_C)]:
y_hat = (p >= 0.5).astype(int)
acc = np.mean(y_hat == y_true)
loss = log_loss(y_true, p)
print(f"Model {name}: accuracy={acc:.2f}, log-loss={loss:.3f}")Model A: accuracy=1.00, log-loss=0.673
Model B: accuracy=1.00, log-loss=0.308
Model C: accuracy=1.00, log-loss=0.010
This is why cross-entropy / log-loss is often a better objective when our decisions depend on the probabilities, not just on the hard labels.
Model: \[ f_\theta(x) = \sigma( \theta^\top x), \quad \sigma(z) = \frac{1}{1+e^{-z}}. \]
Loss for one example \((x,y)\): \[ \ell(\theta; x, y) = -\big(y \log \sigma( \theta^\top x) + (1-y)\log(1-\sigma( \theta^\top x))\big). \]
Key result. \[ \nabla_\theta \ell( \theta; x, y) = \big(\sigma( \theta^\top x) - y\big)\,x. \]
For a dataset, average these gradients over all examples.
import numpy as np
def logistic_loss_single(theta, x, y):
z = theta @ x
p = 1 / (1 + np.exp(-z))
return -(y * np.log(p) + (1 - y) * np.log(1 - p))
def logistic_grad_single(theta, x, y):
z = theta @ x
p = 1 / (1 + np.exp(-z))
return (p - y) * x
rng = np.random.default_rng(42)
theta = rng.normal(size=2)
x = rng.normal(size=2)
y = 1
eps = 1e-5
basis = np.eye(2)
num_grad = np.zeros_like(theta)
for j in range(2):
theta_plus = theta + eps * basis[j]
theta_minus = theta - eps * basis[j]
num_grad[j] = (logistic_loss_single(theta_plus, x, y) -
logistic_loss_single(theta_minus, x, y)) / (2 * eps)
print("Analytic grad:", logistic_grad_single(theta, x, y))
print("Numeric grad: ", num_grad)Analytic grad: [-0.50960822 -0.63870844]
Numeric grad: [-0.50960822 -0.63870844]
🧠 Why did we do this?
It’s like a tiny scientific experiment: hold everything else fixed, perturb one parameter slightly, measure how the loss changes, and compare to what your math predicts should happen.
Numeric and analytic gradients should be very close (up to a small numerical error).
This is a simple way to sanity-check gradient code.
Logistic regression gives us probabilities \(\hat{p}(y=1\mid x)\).
To make a decision, we choose a threshold \(\tau\):
Choosing \(\tau=0.5\) is natural if false positives and false negatives are equally bad.
Let’s see (the head of) some data again:
import pandas as pd
import numpy as np
rng = np.random.default_rng(3151)
n_flights = 3000
# Telemetry from the *first few minutes* of each flight (synthetic):
# - vibration_rms: RMS vibration (g)
# - oil_temp_delta: oil temperature above nominal (°C)
vibration_rms = rng.gamma(shape=2.0, scale=0.70, size=n_flights) # mostly small, occasional spikes
oil_temp_delta = rng.normal(loc=5 + 4.0 * vibration_rms, scale=2.5, size=n_flights)
oil_temp_delta = np.clip(oil_temp_delta, 0, None)
# A steep "tipping point" risk model:
# once vibration and temperature cross a threshold together, failure risk shoots up.
risk_score = 1.3 * vibration_rms + 0.18 * oil_temp_delta
z_true = 10 * (risk_score - 3.2)
p_failure = 1 / (1 + np.exp(-z_true))
failure_24h = rng.binomial(1, p_failure)
# Real life is messy: some high-risk-looking flights are *not* failures (e.g., unusual operating conditions).
# We simulate that by flipping the label for the most extreme telemetry cases.
n_flips = int(0.10 * n_flights)
flip_idx = np.argsort(risk_score)[-n_flips:]
failure_24h[flip_idx] = 1 - failure_24h[flip_idx]
df = pd.DataFrame({
"vibration_rms": vibration_rms,
"oil_temp_delta": oil_temp_delta,
"failure_24h": failure_24h,
})
df.head()| vibration_rms | oil_temp_delta | failure_24h | |
|---|---|---|---|
| 0 | 0.640526 | 7.898066 | 0 |
| 1 | 1.509162 | 10.232553 | 1 |
| 2 | 1.981035 | 13.506731 | 1 |
| 3 | 1.285732 | 10.147697 | 1 |
| 4 | 0.942545 | 7.914355 | 0 |
Now in scatter form (class 1: yellow; class 0: purple):
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
sc = ax.scatter(df["vibration_rms"], df["oil_temp_delta"],
c=df["failure_24h"], cmap="viridis", alpha=0.7)
ax.set_xlabel("vibration_rms (RMS g)")
ax.set_ylabel("oil_temp_delta (°C above nominal)")
ax.set_title("Synthetic jet-engine telemetry")
cbar = plt.colorbar(sc, ax=ax)
cbar.set_label("failure_24h (0/1)")
plt.tight_layout()
plt.show()First, split the data into train and test with sklearn
Then, train (i.e., fit) an actual LinearRegression() and get the test set accuracy
from sklearn.linear_model import LinearRegression
from sklearn.metrics import accuracy_score
lin_reg = LinearRegression()
lin_reg.fit(X_train_c, y_train_c)
train_pred_reg = lin_reg.predict(X_train_c)
test_pred_reg = lin_reg.predict(X_test_c)
# Clip to [0,1] and threshold
train_probs_reg = np.clip(train_pred_reg, 0, 1)
test_probs_reg = np.clip(test_pred_reg, 0, 1)
train_pred_labels_reg = (train_probs_reg >= 0.5).astype(int)
test_pred_labels_reg = (test_probs_reg >= 0.5).astype(int)
acc_reg = accuracy_score(y_test_c, test_pred_labels_reg)
acc_reg0.6293333333333333
This gives us a naïve linear regression-based classifier.
We’ll compare it to logistic regression and a simple baseline.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import log_loss
# Fit logistic regression
log_reg = LogisticRegression(random_state=3151)
log_reg.fit(X_train_c, y_train_c)
train_probs_lr = log_reg.predict_proba(X_train_c)[:, 1]
test_probs_lr = log_reg.predict_proba(X_test_c)[:, 1]
train_pred_labels_lr = (train_probs_lr >= 0.5).astype(int)
test_pred_labels_lr = (test_probs_lr >= 0.5).astype(int)
acc_log = accuracy_score(y_test_c, test_pred_labels_lr)
train_logloss_lr = log_loss(y_train_c, train_probs_lr)
test_logloss_lr = log_loss(y_test_c, test_probs_lr)
# Baseline: always predict training positive rate
p_base = y_train_c.mean()
train_probs_base = np.full_like(y_train_c, p_base, dtype=float)
test_probs_base = np.full_like(y_test_c, p_base, dtype=float)
train_logloss_base = log_loss(y_train_c, train_probs_base)
test_logloss_base = log_loss(y_test_c, test_probs_base)
acc_base = accuracy_score(y_test_c, (test_probs_base >= 0.5).astype(int))
import pandas as pd
results = pd.DataFrame([
{
"model": "baseline",
"train_logloss": train_logloss_base,
"test_logloss": test_logloss_base,
"test_accuracy": acc_base
},
{
"model": "naive_linear_regression",
"train_logloss": np.nan,
"test_logloss": np.nan,
"test_accuracy": acc_reg
},
{
"model": "logistic_regression",
"train_logloss": train_logloss_lr,
"test_logloss": test_logloss_lr,
"test_accuracy": acc_log
}
])
results| model | train_logloss | test_logloss | test_accuracy | |
|---|---|---|---|---|
| 0 | baseline | 0.680292 | 0.680292 | 0.580000 |
| 1 | naive_linear_regression | NaN | NaN | 0.629333 |
| 2 | logistic_regression | 0.629637 | 0.636613 | 0.645333 |
x0 = np.linspace(df["vibration_rms"].min(), df["vibration_rms"].max(), 200)
x1 = np.linspace(df["oil_temp_delta"].min(), df["oil_temp_delta"].max(), 200)
xx, yy = np.meshgrid(x0, x1)
grid = np.c_[xx.ravel(), yy.ravel()]
grid_probs = log_reg.predict_proba(grid)[:, 1].reshape(xx.shape)
fig, ax = plt.subplots()
sc = ax.scatter(df["vibration_rms"], df["oil_temp_delta"],
c=df["failure_24h"], cmap="viridis", alpha=0.6)
cs = ax.contour(xx, yy, grid_probs, levels=[0.5], colors="red", linestyles="--")
ax.clabel(cs, inline=True, fontsize=8)
ax.set_xlabel("vibration_rms")
ax.set_ylabel("oil_temp_delta")
ax.set_title("Decision boundary (P(failure_24h=1) = 0.5)")
plt.colorbar(sc, ax=ax, label="failure_24h")
plt.tight_layout()
plt.show()
The red dashed line shows where the model is undecided (\(P=0.5\)).
Points on one side are mostly predicted active; on the other, inactive.
import plotly.graph_objects as go
x0 = np.linspace(df["vibration_rms"].min(), df["vibration_rms"].max(), 50)
x1 = np.linspace(df["oil_temp_delta"].min(), df["oil_temp_delta"].max(), 50)
xx, yy = np.meshgrid(x0, x1)
grid = np.c_[xx.ravel(), yy.ravel()]
grid_probs = log_reg.predict_proba(grid)[:, 1].reshape(xx.shape)
surface_fig = go.Figure(
data=[
go.Surface(
x=x0,
y=x1,
z=grid_probs,
colorbar_title="P(active)",
)
]
)
surface_fig.update_layout(
title="Probability surface P(failure_24h=1)",
scene=dict(
xaxis_title="vibration_rms",
yaxis_title="oil_temp_delta",
zaxis_title="P(failure_24h=1)",
)
)
surface_fig.show()from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
def evaluate_threshold(threshold):
y_pred = (test_probs_lr >= threshold).astype(int)
cm = confusion_matrix(y_test_c, y_pred)
disp = ConfusionMatrixDisplay(cm, display_labels=["inactive", "active"])
disp.plot(values_format="d")
plt.title(f"Confusion matrix at threshold={threshold:.2f}")
plt.tight_layout()
plt.show()
acc = accuracy_score(y_test_c, y_pred)
print(f"Accuracy at threshold={threshold:.2f}: {acc:.3f}")
evaluate_threshold(0.5)
Accuracy at threshold=0.50: 0.645
In this early-warning system:
Trade-offs:
Logistic regression (and its generalizations) underpins many real systems:
Open questions / research directions:
Why is logistic regression better suited than linear regression for 0/1 labels?
A. It’s faster to train.
B. It outputs values in [0,1] and models probabilities directly.
C. It can’t overfit.
D. It doesn’t need a train/test split.
The logistic loss for a positive example (\(y=1\)) is:
A. \(-\log(1-\hat{p})\)
B. \(-\log(\hat{p})\)
C. \(\hat{p} - y\)
D. \((\hat{p} - y)^2\)
The gradient of the logistic loss w.r.t. $ heta$ for one example is proportional to:
A. \((y - \hat{p}) x\)
B. \((\hat{p} - y) x\)
C. \(x\)
D. \(y x\)
In our student-engagement example, which metric is most aligned with the decision to flag at-risk engines?
A. Overall accuracy.
B. Recall for the at-risk (inactive) class.
C. Number of parameters in the model.
D. Training set size.
