MPS311/439 Machine Learning - Dr. Wei Xing
Last week, we explored how to extend linear regression through feature engineering and regularization. We learned how to create polynomial features to capture non-linear patterns and how Ridge and Lasso regression can prevent overfitting by controlling model complexity. These techniques gave us powerful tools for predicting continuous outcomes like house prices or temperatures.
But what happens when we want to predict categories instead of numbers? What if we want to know whether an email is spam or not? Whether a patient has a disease? Whether a student will pass or fail? These are classification problems, and they require a different approach.
This week, we'll discover how to adapt our linear models for classification tasks. We'll learn about logistic regression, understand why it uses something called the sigmoid function, and explore how to properly evaluate classification models. By the end, you'll be able to build models that make yes/no decisions with confidence scores!
Classification is the task of predicting which discrete category (or class) an observation belongs to, based on its features. Unlike regression where we predict continuous values, classification predicts discrete labels.
Real-world examples:
In this lecture, we'll focus on binary classification where there are exactly two classes, typically labeled as 0 and 1 (or negative and positive).
You might wonder: "Why can't we just use what we learned last week? Can't we treat the classes as numbers (0 and 1) and use linear regression?"
Great question! Let's see what happens when we try. Imagine we're predicting whether students will pass or fail based on hours studied.

The fundamental problems:
Impossible predictions: Linear regression can predict values less than 0 or greater than 1. But probabilities must be between 0 and 1! What does it mean to have a "-0.2 probability" of passing?
Sensitivity to outliers: Notice the orange star in the figure? That's a student who studied only 1 hour but passed (maybe they're naturally talented, or got lucky). This single outlier drastically affects the regression line, making predictions worse for everyone else.
No probability interpretation: Even if predictions happen to fall between 0 and 1, linear regression doesn't give us proper probabilities. The output doesn't have the mathematical properties that probabilities require.
Wrong loss function: We'll see later that squared error (which we used for regression) isn't the right way to measure classification errors.
For classification, we need a model that:
Logistic regression gives us all of these! Despite its name containing "regression", logistic regression is actually a classification algorithm. The name comes from the fact that it uses regression techniques internally, but its output is a classification decision.
Here's the key insight: We can still use a linear combination of features (just like linear regression), but we'll pass it through a special function that squashes any real number into the range (0, 1).
The two-step process:
This special function is called the sigmoid function (also known as the logistic function).
The sigmoid function is defined as:
Let's visualize what this function does:

Why sigmoid is perfect for our needs:
Key properties to remember:
Now we can define our complete model:
Step 1: Compute the linear combination
Or in vector notation:
Step 2: Apply sigmoid to get probability
Interpretation of weights:
Example: Imagine predicting disease risk:
Once we have , how do we actually make a prediction? We need a decision rule:
Decision Rule:
Since , this is equivalent to:
The decision boundary is the line (or hyperplane) where . This is still a linear boundary, just like in linear regression!
Let's see this in action with two features:

In this figure:
Key insight: Even though we're using sigmoid to get probabilities, the decision boundary itself is still linear. This means logistic regression works best when the two classes can be separated (at least approximately) by a straight line or hyperplane.
Now we know what logistic regression predicts, but how do we find the best weights ? We need to define what "best" means by choosing an appropriate loss function.
In linear regression, we used the squared error:
For classification, this seems natural at first:
But there's a critical problem: When we use squared error with the sigmoid function, the resulting optimization landscape is non-convex. This means:
We need a loss function that creates a convex optimization problem, guaranteeing that gradient descent will find the best solution.
The cross-entropy loss (also called log loss) is specifically designed for probability predictions:
For a single sample:
Where:
Let's understand this intuitively:
Case 1: When true label
Case 2: When true label
Beautiful properties:
Fortunately, we don't need to implement all the optimization mathematics ourselves. Sklearn provides a clean interface:
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
# Split data
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# Create and train model
model = LogisticRegression()
model.fit(X_train, y_train)
# Get class predictions (0 or 1)
y_pred = model.predict(X_test)
# Get probability predictions
y_prob = model.predict_proba(X_test)
# Returns array like: [[P(class 0), P(class 1)] for each sample]
# Access learned parameters
print("Intercept:", model.intercept_)
print("Coefficients:", model.coef_)
Key methods:
.fit(X, y): Train the model on training data.predict(X): Get hard predictions (0 or 1) using the 0.5 threshold.predict_proba(X): Get probability estimates for each class.coef_ and .intercept_: Access the learned weightsLet's see logistic regression in action with minimal code:
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
# Generate simple binary classification data
X, y = make_classification(n_samples=100, n_features=2, n_redundant=0,
n_clusters_per_class=1, random_state=42)
# Train logistic regression
model = LogisticRegression()
model.fit(X, y)
# Make predictions
y_pred = model.predict(X)
y_prob = model.predict_proba(X)[:, 1] # Get P(y=1) for each sample
# Show a few examples
for i in range(5):
print(f"Sample {i}: True={y[i]}, Predicted={y_pred[i]}, P(y=1)={y_prob[i]:.3f}")
Output might look like:
Sample 0: True=1, Predicted=1, P(y=1)=0.912
Sample 1: True=0, Predicted=0, P(y=1)=0.143
Sample 2: True=1, Predicted=1, P(y=1)=0.876
Sample 3: True=0, Predicted=0, P(y=1)=0.089
Sample 4: True=1, Predicted=0, P(y=1)=0.487
Notice sample 4: The model predicted class 0, but the true label was 1, and the probability was 0.487 (very close to the decision threshold). This is an uncertain prediction where the model could easily be wrong.
Training a model is only half the battle. How do we know if it's actually good? For regression, we used metrics like MSE or R². For classification, we need different tools.
The most obvious metric is accuracy: what percentage of predictions are correct?
This seems reasonable, but it can be very misleading!
Example - Email Spam Detection:
Imagine 95% of emails are legitimate (not spam), and only 5% are spam.
Consider a "naive model" that simply predicts "not spam" for every single email:
This is the problem of imbalanced classes: when one class is much more common than the other, accuracy doesn't tell the full story.
When accuracy IS useful:
When accuracy is MISLEADING:
To truly understand how our classifier performs, we need to break down its predictions into four categories:

The four outcomes:
True Positive (TP): Model predicted 1, and the true label was 1 ✓
True Negative (TN): Model predicted 0, and the true label was 0 ✓
False Positive (FP): Model predicted 1, but true label was 0 ✗
False Negative (FN): Model predicted 0, but true label was 1 ✗
Different scenarios care about different errors:
Creating a confusion matrix in Python:
from sklearn.metrics import confusion_matrix
y_true = [1, 0, 1, 1, 0, 1, 0, 0]
y_pred = [1, 0, 1, 0, 0, 1, 0, 1]
cm = confusion_matrix(y_true, y_pred)
print(cm)
# Output:
# [[3 1] <- Row 0: True negatives and false positives
# [1 3]] <- Row 1: False negatives and true positives
From the confusion matrix, we can compute metrics that focus on different aspects of performance:
High precision means: Few false alarms
Example: In email spam filtering:
High recall means: Few missed detections
Example: In disease screening:
The F1-score balances precision and recall:
Let's visualize how different classifiers perform on these metrics:

Understanding the different classifier types:
Computing metrics in Python:
from sklearn.metrics import precision_score, recall_score, f1_score, classification_report
y_true = [1, 0, 1, 1, 0, 1, 0, 0]
y_pred = [1, 0, 1, 0, 0, 1, 0, 1]
precision = precision_score(y_true, y_pred)
recall = recall_score(y_true, y_pred)
f1 = f1_score(y_true, y_pred)
print(f"Precision: {precision:.3f}")
print(f"Recall: {recall:.3f}")
print(f"F1-Score: {f1:.3f}")
# Or get everything at once:
print(classification_report(y_true, y_pred))
Here's a fundamental insight: You usually can't maximize both precision and recall simultaneously. There's a tradeoff.
Remember that by default, logistic regression predicts class 1 when P(y=1) > 0.5. But we can change this threshold!
Increasing the threshold (e.g., to 0.7):
Decreasing the threshold (e.g., to 0.3):
Practical example - Medical diagnosis:
Conservative threshold (0.8): Only diagnose disease when very confident
Aggressive threshold (0.2): Diagnose disease with low confidence
Adjusting the threshold in Python:
# Get probability predictions
y_prob = model.predict_proba(X_test)[:, 1]
# Use custom threshold
threshold = 0.7
y_pred_custom = (y_prob > threshold).astype(int)
# Compare with default threshold (0.5)
y_pred_default = model.predict(X_test)
Instead of choosing a single threshold, we can evaluate model performance across all possible thresholds using the ROC curve (Receiver Operating Characteristic).
The ROC curve plots:
Each point on the curve represents a different classification threshold.

Interpreting the ROC curve:
Diagonal line (y=x): Random guessing baseline
Curve above diagonal: Model is useful
Top-left corner: Perfect classifier
Area Under Curve (AUC): Summary metric
What AUC really means:
AUC represents the probability that the model ranks a random positive sample higher than a random negative sample. In other words, if you pick a positive example and a negative example at random, AUC is the probability that your model assigns a higher score to the positive one.
Creating ROC curves in Python:
from sklearn.metrics import roc_curve, roc_auc_score
import matplotlib.pyplot as plt
# Get probability predictions
y_prob = model.predict_proba(X_test)[:, 1]
# Compute ROC curve
fpr, tpr, thresholds = roc_curve(y_test, y_prob)
auc_score = roc_auc_score(y_test, y_prob)
# Plot
plt.plot(fpr, tpr, label=f'ROC Curve (AUC = {auc_score:.2f})')
plt.plot([0, 1], [0, 1], 'k--', label='Random Guess')
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.title('ROC Curve')
plt.legend()
plt.show()
print(f"AUC Score: {auc_score:.3f}")
With so many metrics, how do you choose? Here's a decision guide:
| Scenario | Recommended Metric | Reasoning |
|---|---|---|
| Balanced classes + equal error costs | Accuracy | Simple and interpretable |
| Imbalanced classes | F1-Score, Precision, or Recall | Accuracy is misleading |
| False positives are very costly | Precision | Minimize false alarms |
| False negatives are very costly | Recall | Don't miss positive cases |
| Need to compare models overall | AUC | Threshold-independent |
| Need to choose operating point | ROC Curve | Shows all precision-recall tradeoffs |
| Real costs for FP and FN | Cost-sensitive evaluation | Incorporate business costs |
Examples:
Let's bring everything together with a complete worked example. We'll use a real dataset to predict heart disease risk.
import numpy as np
import pandas as pd
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, classification_report, roc_auc_score
# Load breast cancer dataset (binary classification)
data = load_breast_cancer()
X = data.data
y = data.target # 0 = malignant, 1 = benign
print(f"Dataset shape: {X.shape}")
print(f"Features: {data.feature_names[:5]}...") # Show first 5 features
print(f"Class distribution: {np.bincount(y)}") # Count of each class
# Split into train and test sets
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
Output:
Dataset shape: (569, 30)
Features: ['mean radius' 'mean texture' 'mean perimeter' 'mean area' 'mean smoothness']...
Class distribution: [212 357]
# Create and train logistic regression model
model = LogisticRegression(max_iter=10000, random_state=42)
model.fit(X_train, y_train)
# Make predictions
y_pred = model.predict(X_test)
y_prob = model.predict_proba(X_test)[:, 1]
print("Training complete!")
print(f"Training accuracy: {model.score(X_train, y_train):.3f}")
print(f"Testing accuracy: {model.score(X_test, y_test):.3f}")
# Confusion matrix
cm = confusion_matrix(y_test, y_pred)
print("\nConfusion Matrix:")
print(cm)
# Detailed classification report
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=['Malignant', 'Benign']))
# AUC score
auc = roc_auc_score(y_test, y_prob)
print(f"\nAUC Score: {auc:.3f}")
Output:
Confusion Matrix:
[[ 59 4]
[ 3 105]]
Classification Report:
precision recall f1-score support
Malignant 0.95 0.94 0.94 63
Benign 0.96 0.97 0.97 108
accuracy 0.96 171
AUC Score: 0.991
Interpretation:
# Get feature names and coefficients
feature_names = data.feature_names
coefficients = model.coef_[0]
# Create dataframe for better visualization
coef_df = pd.DataFrame({
'Feature': feature_names,
'Coefficient': coefficients
}).sort_values('Coefficient', key=abs, ascending=False)
print("\nTop 5 Most Important Features:")
print(coef_df.head())
Example output:
Feature Coefficient
worst perimeter 2.156
worst concave points 1.842
mean concave points 1.234
worst radius 0.987
worst texture -0.654
Interpretation:
Important note: To properly interpret coefficients, features should be standardized (same scale). Otherwise, features with larger ranges will appear to have smaller coefficients just due to their scale.
# Show some examples with probabilities
print("\nSample Predictions:")
print("True | Pred | P(Benign) | Confidence")
print("-" * 40)
for i in range(10):
true_label = "Benign" if y_test[i] == 1 else "Malignant"
pred_label = "Benign" if y_pred[i] == 1 else "Malignant"
prob = y_prob[i]
confidence = max(prob, 1 - prob)
print(f"{true_label:10} | {pred_label:10} | {prob:.3f} | {confidence:.3f}")
Example output:
True | Pred | P(Benign) | Confidence
----------------------------------------
Benign | Benign | 0.982 | 0.982
Malignant | Malignant | 0.045 | 0.955
Benign | Benign | 0.917 | 0.917
Benign | Benign | 0.523 | 0.523 <- Low confidence!
Malignant | Malignant | 0.112 | 0.888
Key observations:
Logistic regression is a great choice when:
✅ Classes are approximately linearly separable
✅ You need interpretable results
✅ You want probability estimates
✅ As a baseline model
✅ With high-dimensional data
Logistic regression has limitations:
❌ Non-linear decision boundaries
❌ Feature engineering burden
❌ Multicollinearity
Let's visualize when logistic regression succeeds vs fails:

Left panel: Linearly separable data
Right panel: Non-linearly separable data (XOR pattern)
Solutions for non-linear problems:
Feature engineering: Create polynomial features, like we did in Week 3
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=2, include_bias=False)
X_poly = poly.fit_transform(X)
Use non-linear models: Decision trees (Week 6), neural networks (Weeks 10-11)
Kernel methods: Transform features into higher dimensions (advanced topic)
As we've seen, logistic regression always produces linear decision boundaries. This is both a strength (simple, interpretable) and a limitation (can't capture complex patterns).
What we mean by "linear":
Real-world data often has non-linear patterns:
So far, we've focused on binary classification (2 classes). But what about predicting among multiple categories?
Examples:
Two main approaches:
One-vs-Rest (OvR):
Softmax Regression (Multinomial Logistic Regression):
Good news: Sklearn handles multi-class automatically!
# Works the same way for 2, 3, or more classes!
model = LogisticRegression(multi_class='auto')
model.fit(X_train, y_train)
y_pred = model.predict(X_test) # Works for K classes
y_prob = model.predict_proba(X_test) # Returns K probabilities per sample
Next week, we'll learn about LDA (Linear Discriminant Analysis) and QDA (Quadratic Discriminant Analysis). These are alternative approaches to classification that:
Linear Discriminant Analysis (LDA):
Quadratic Discriminant Analysis (QDA):
Key comparison:
| Method | Boundary Shape | Approach | Best When |
|---|---|---|---|
| Logistic Regression | Linear | Discriminative (models ) | Large datasets, need probabilities |
| LDA | Linear | Generative (models ) | Small datasets, Gaussian features |
| QDA | Quadratic/Curved | Generative (models ) | Non-linear patterns, enough data |
Let's recap what you've learned this week:
Classification predicts categories, not continuous values
Logistic regression uses sigmoid to get probabilities
Cross-entropy loss is designed for classification
Multiple metrics are needed to evaluate classifiers
Linear decision boundaries are both strength and limitation
Congratulations! You now understand one of the most widely-used machine learning algorithms. Logistic regression is everywhere - from medical diagnosis to credit scoring to online advertising. Its simplicity and interpretability make it a go-to choice for many real-world applications.
This section is for MPS439 students. MPS311 students: feel free to read if curious, but this material is not required for your coursework.
Where does the cross-entropy loss actually come from? It's not arbitrary - it emerges naturally from maximum likelihood estimation.
Goal: Find parameters that make our observed data most likely.
For a single training example :
We can write both cases compactly as:
Why this works:
For independent samples, the likelihood of the entire dataset is:
Products are hard to optimize. Taking the logarithm converts products to sums:
Why logarithm?
Machine learning convention: minimize loss rather than maximize likelihood.
Negative log-likelihood per sample:
Where
This is exactly the cross-entropy loss!
Key insight: Minimizing cross-entropy is equivalent to maximizing likelihood of correct labels
With linear regression, we had a closed-form solution (normal equation). With logistic regression, we must use iterative optimization.
The good news: The optimization landscape is convex, meaning:
Mathematical insight (intuition, not rigorous proof):
This is why we use cross-entropy instead of squared error - it gives us the mathematical guarantee of finding the best solution!
Let's implement logistic regression ourselves to see what's happening under the hood.
Through calculus (chain rule through sigmoid and log), we can show that:
Gradient of loss with respect to weights:
Where:
Beautiful result: This has exactly the same form as linear regression! The only difference is that uses sigmoid instead of being directly equal to .
Derivation sketch (for the curious):
By chain rule:
Where:
And (key sigmoid derivative property):
Combining (algebra omitted):
Pseudocode:
Where is the learning rate (step size).
Here's a minimal implementation (~30 lines):
import numpy as np
def sigmoid(z):
"""Sigmoid function with clipping to avoid overflow"""
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def compute_loss(X, y, w):
"""Cross-entropy loss"""
n = len(y)
predictions = sigmoid(X @ w)
# Clip to avoid log(0)
predictions = np.clip(predictions, 1e-10, 1 - 1e-10)
loss = -np.mean(y * np.log(predictions) + (1 - y) * np.log(1 - predictions))
return loss
def gradient_descent_logistic(X, y, learning_rate=0.1, max_iters=1000):
"""Train logistic regression using gradient descent"""
n, p = X.shape
# Add intercept term
X_with_intercept = np.c_[np.ones(n), X]
# Initialize weights
w = np.zeros(p + 1)
# Store losses for visualization
losses = []
# Gradient descent loop
for iteration in range(max_iters):
# Forward pass: compute predictions
predictions = sigmoid(X_with_intercept @ w)
# Compute gradient
gradient = X_with_intercept.T @ (predictions - y) / n
# Update weights
w = w - learning_rate * gradient
# Track loss
loss = compute_loss(X_with_intercept, y, w)
losses.append(loss)
# Optional: print progress
if iteration % 100 == 0:
print(f"Iteration {iteration}, Loss: {loss:.4f}")
return w, losses
# Example usage
from sklearn.datasets import make_classification
# Generate data
X, y = make_classification(n_samples=200, n_features=2, n_redundant=0,
n_clusters_per_class=1, random_state=42)
# Train our implementation
weights, losses = gradient_descent_logistic(X, y, learning_rate=0.1, max_iters=500)
print(f"\nFinal weights: {weights}")
print(f"Final loss: {losses[-1]:.4f}")
Let's see how loss decreases over iterations with different learning rates:

Observations:
Good learning rate (, blue solid line):
Too low learning rate (, green dotted line):
Too high learning rate (, red dashed line):
Choosing learning rate:
from sklearn.linear_model import LogisticRegression
# Our implementation
w_ours, _ = gradient_descent_logistic(X, y, learning_rate=0.1, max_iters=1000)
# Sklearn's implementation
model_sklearn = LogisticRegression(max_iter=1000)
model_sklearn.fit(X, y)
w_sklearn = np.concatenate([[model_sklearn.intercept_[0]], model_sklearn.coef_[0]])
print("Our weights: ", w_ours)
print("Sklearn weights:", w_sklearn)
print("Difference: ", np.abs(w_ours - w_sklearn))
Why sklearn is better:
But understanding the implementation helps you know what's happening inside the black box!
Logistic regression is actually a single-layer neural network!
The components:
Deep learning (Weeks 10-11) extends this by:
Gradient descent still works because:
Understanding logistic regression is your first step toward understanding deep learning!
Just like with linear regression (Week 3), we can add regularization to logistic regression:
L2 Regularization (Ridge):
L1 Regularization (Lasso):
In sklearn:
# L2 regularization (default)
model_ridge = LogisticRegression(penalty='l2', C=1.0)
# L1 regularization
model_lasso = LogisticRegression(penalty='l1', solver='liblinear', C=1.0)
# Note: C is inverse of regularization strength
# Smaller C = more regularization
Where (inverse of regularization strength)
When to use:
By now, you should be able to:
✅ Apply LogisticRegression for binary classification
predict() for class labels and predict_proba() for probabilities✅ Explain why we use sigmoid function for probabilities
✅ Describe when linear classification fails
✅ Interpret confusion matrices and classification metrics
Additionally, you should be able to:
✅ Derive cross-entropy loss function
✅ Implement logistic regression using gradient descent
This week's lab: You'll practice applying logistic regression to real datasets, tuning thresholds, and comparing different evaluation metrics.
Next week: Linear and Quadratic Discriminant Analysis (LDA/QDA) - alternative approaches to classification that model class distributions and can handle curved decision boundaries.
Looking ahead: Decision Trees (Week 6) will give us truly non-linear decision boundaries without requiring manual feature engineering!
For deeper understanding:
Practice datasets:
Good luck with your studies! Remember to experiment with the code and visualize your results. Machine learning is best learned by doing!
Dr. Wei Xing