Maximizing the Log-Likelihood in Logistic Regression

A Hands-On Example with Gradient Ascent vs. Newton-Raphson

Logistic regression is one of the most widely used models for binary classification, and at its core lies a beautiful optimization problem: finding the parameter vector that maximizes the log-likelihood of the observed data. In this article, we’ll build a concrete example from scratch — a synthetic medical diagnosis dataset — and solve the maximum likelihood estimation (MLE) problem using two different optimization strategies: plain gradient ascent and the much faster Newton-Raphson (IRLS) method.

The Problem Setup

Suppose we have $n$ observations, each with a feature vector $\mathbf{x}_i \in \mathbb{R}^p$ and a binary label $y_i \in {0, 1}$. Logistic regression models the probability of the positive class as:

$$P(y_i = 1 \mid \mathbf{x}_i) = \sigma(\mathbf{x}_i^\top \boldsymbol{\beta}) = \frac{1}{1 + e^{-\mathbf{x}_i^\top \boldsymbol{\beta}}}$$

where $\boldsymbol{\beta}$ is the parameter vector we want to estimate (including an intercept term).

The Log-Likelihood Function

Assuming the observations are independent, the likelihood of the entire dataset is:

$$L(\boldsymbol{\beta}) = \prod_{i=1}^{n} \sigma(\mathbf{x}_i^\top \boldsymbol{\beta})^{y_i} \left(1 - \sigma(\mathbf{x}_i^\top \boldsymbol{\beta})\right)^{1 - y_i}$$

Taking the logarithm turns this product into a sum, which is far easier to optimize:

$$\ell(\boldsymbol{\beta}) = \sum_{i=1}^{n} \left[ y_i \log \sigma(\mathbf{x}_i^\top \boldsymbol{\beta}) + (1 - y_i) \log\left(1 - \sigma(\mathbf{x}_i^\top \boldsymbol{\beta})\right) \right]$$

Our goal is:

$$\boldsymbol{\beta}^{*} = \underset{\boldsymbol{\beta}}{\arg\max}\ \ell(\boldsymbol{\beta})$$

Since $\ell(\boldsymbol{\beta})$ is concave in $\boldsymbol{\beta}$, this problem has a unique global maximum, which makes it a perfect candidate for gradient-based optimization.

Gradient of the Log-Likelihood

Differentiating $\ell(\boldsymbol{\beta})$ with respect to $\boldsymbol{\beta}$ gives a remarkably clean expression:

$$\nabla \ell(\boldsymbol{\beta}) = \mathbf{X}^\top (\mathbf{y} - \boldsymbol{\sigma})$$

where $\boldsymbol{\sigma} = \sigma(\mathbf{X}\boldsymbol{\beta})$ is the vector of predicted probabilities. This gradient tells us how to move $\boldsymbol{\beta}$ to increase the log-likelihood, and it’s the basis of gradient ascent:

$$\boldsymbol{\beta}^{(t+1)} = \boldsymbol{\beta}^{(t)} + \eta \nabla \ell(\boldsymbol{\beta}^{(t)})$$

Gradient ascent is simple, but it can take hundreds or thousands of iterations to converge, especially when features are on different scales or the log-likelihood surface is elongated (ill-conditioned).

Speeding Things Up: Newton-Raphson / IRLS

To converge dramatically faster, we can use second-order information — the Hessian of the log-likelihood:

$$H(\boldsymbol{\beta}) = -\mathbf{X}^\top \mathbf{W} \mathbf{X}, \qquad \mathbf{W} = \mathrm{diag}\big(\sigma_i(1-\sigma_i)\big)$$

The Newton-Raphson update, also known as Iteratively Reweighted Least Squares (IRLS) in the context of logistic regression, is:

$$\boldsymbol{\beta}^{(t+1)} = \boldsymbol{\beta}^{(t)} - H(\boldsymbol{\beta}^{(t)})^{-1} \nabla \ell(\boldsymbol{\beta}^{(t)})$$

Because it uses curvature information, Newton-Raphson typically converges in fewer than 10 iterations, compared to hundreds for plain gradient ascent — a huge speedup when the dataset or the number of features grows.

The Example: A Synthetic Tumor Diagnosis Dataset

We’ll generate a synthetic dataset with two features — “tumor size” and “cell irregularity score” — and a binary label indicating malignant (1) or benign (0). We’ll then fit logistic regression using both gradient ascent and Newton-Raphson, compare their convergence speed, and visualize the log-likelihood landscape and the resulting decision boundary in 3D.

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# ==========================================================
# Logistic Regression via Log-Likelihood Maximization
# Gradient Ascent vs. Newton-Raphson (IRLS)
# ==========================================================

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# ----------------------------------------------------------
# 0. Global settings
# ----------------------------------------------------------
np.random.seed(42)
plt.style.use('dark_background')

DARK_BG = '#0d1117'
GRID_COLOR = '#30363d'

def style_3d_axis(ax):
ax.set_facecolor(DARK_BG)
ax.xaxis.pane.set_facecolor(DARK_BG)
ax.yaxis.pane.set_facecolor(DARK_BG)
ax.zaxis.pane.set_facecolor(DARK_BG)
ax.xaxis.pane.set_edgecolor(GRID_COLOR)
ax.yaxis.pane.set_edgecolor(GRID_COLOR)
ax.zaxis.pane.set_edgecolor(GRID_COLOR)
ax.grid(True, color=GRID_COLOR, linewidth=0.4)

# ----------------------------------------------------------
# 1. Generate a synthetic tumor diagnosis dataset
# ----------------------------------------------------------
n_samples = 400

benign_size = np.random.normal(3.0, 1.0, n_samples // 2)
benign_irregularity = np.random.normal(3.0, 1.0, n_samples // 2)

malignant_size = np.random.normal(7.0, 1.2, n_samples // 2)
malignant_irregularity = np.random.normal(7.0, 1.2, n_samples // 2)

tumor_size = np.concatenate([benign_size, malignant_size])
irregularity = np.concatenate([benign_irregularity, malignant_irregularity])
labels = np.concatenate([np.zeros(n_samples // 2), np.ones(n_samples // 2)])

# Standardize features for numerically stable optimization
size_std = (tumor_size - tumor_size.mean()) / tumor_size.std()
irregularity_std = (irregularity - irregularity.mean()) / irregularity.std()

X = np.column_stack([np.ones(n_samples), size_std, irregularity_std]) # bias + 2 features
y = labels

# ----------------------------------------------------------
# 2. Core logistic regression functions
# ----------------------------------------------------------
def sigmoid(z):
z = np.clip(z, -500, 500) # avoid overflow
return 1.0 / (1.0 + np.exp(-z))

def log_likelihood(beta, X, y):
z = X @ beta
p = sigmoid(z)
eps = 1e-12
return np.sum(y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps))

def gradient(beta, X, y):
p = sigmoid(X @ beta)
return X.T @ (y - p)

def hessian(beta, X):
p = sigmoid(X @ beta)
W = p * (1 - p)
return -(X.T * W) @ X

# ----------------------------------------------------------
# 3. Gradient Ascent optimizer
# ----------------------------------------------------------
def gradient_ascent(X, y, lr=0.01, n_iter=1500):
beta = np.zeros(X.shape[1])
history = np.zeros(n_iter)
for t in range(n_iter):
grad = gradient(beta, X, y)
beta = beta + lr * grad
history[t] = log_likelihood(beta, X, y)
return beta, history

# ----------------------------------------------------------
# 4. Newton-Raphson (IRLS) optimizer -- fast version
# ----------------------------------------------------------
def newton_raphson(X, y, n_iter=15, tol=1e-8):
beta = np.zeros(X.shape[1])
history = []
for t in range(n_iter):
grad = gradient(beta, X, y)
H = hessian(beta, X)
step = np.linalg.solve(H, grad) # solve H * step = grad (faster & more stable than inv(H))
beta = beta - step
ll = log_likelihood(beta, X, y)
history.append(ll)
if np.linalg.norm(step) < tol:
break
return beta, np.array(history)

# ----------------------------------------------------------
# 5. Run both optimizers
# ----------------------------------------------------------
beta_ga, history_ga = gradient_ascent(X, y, lr=0.01, n_iter=1500)
beta_nr, history_nr = newton_raphson(X, y, n_iter=15)

final_ll_ga = history_ga[-1]
final_ll_nr = history_nr[-1]

print("===== Gradient Ascent =====")
print(f"Iterations run : {len(history_ga)}")
print(f"Final coefficients : {beta_ga}")
print(f"Final log-likelihood: {final_ll_ga:.6f}")
print()
print("===== Newton-Raphson (IRLS) =====")
print(f"Iterations run : {len(history_nr)}")
print(f"Final coefficients : {beta_nr}")
print(f"Final log-likelihood: {final_ll_nr:.6f}")

# ----------------------------------------------------------
# 6. Plot 1: Convergence comparison (2D)
# ----------------------------------------------------------
fig1, ax1 = plt.subplots(figsize=(9, 6), facecolor=DARK_BG)
ax1.set_facecolor(DARK_BG)
ax1.plot(np.arange(1, len(history_ga) + 1), history_ga,
color='#58a6ff', linewidth=2, label='Gradient Ascent')
ax1.plot(np.arange(1, len(history_nr) + 1), history_nr,
color='#f78166', linewidth=2, marker='o', markersize=4, label='Newton-Raphson')
ax1.set_xscale('log')
ax1.set_xlabel('Iteration (log scale)', fontsize=12)
ax1.set_ylabel('Log-Likelihood', fontsize=12)
ax1.set_title('Convergence Speed: Gradient Ascent vs. Newton-Raphson', fontsize=14)
ax1.legend(fontsize=11)
ax1.grid(True, color=GRID_COLOR, linewidth=0.4)
plt.tight_layout()
plt.show()

# ----------------------------------------------------------
# 7. Plot 2: 3D log-likelihood surface (slice over 2 of the 3 params)
# Bias term fixed at its optimal (Newton-Raphson) value
# ----------------------------------------------------------
b1_range = np.linspace(beta_nr[1] - 3, beta_nr[1] + 3, 80)
b2_range = np.linspace(beta_nr[2] - 3, beta_nr[2] + 3, 80)
B1, B2 = np.meshgrid(b1_range, b2_range)

LL_surface = np.zeros_like(B1)
for i in range(B1.shape[0]):
for j in range(B1.shape[1]):
beta_temp = np.array([beta_nr[0], B1[i, j], B2[i, j]])
LL_surface[i, j] = log_likelihood(beta_temp, X, y)

fig2 = plt.figure(figsize=(10, 8), facecolor=DARK_BG)
ax2 = fig2.add_subplot(111, projection='3d')
style_3d_axis(ax2)

surf = ax2.plot_surface(B1, B2, LL_surface, cmap='plasma',
linewidth=0, antialiased=True, alpha=0.9)
ax2.set_xlabel('beta_1 (tumor size)', fontsize=10)
ax2.set_ylabel('beta_2 (irregularity)', fontsize=10)
ax2.set_zlabel('Log-Likelihood', fontsize=10)
ax2.set_title('3D Log-Likelihood Landscape', fontsize=14)
ax2.scatter([beta_nr[1]], [beta_nr[2]], [final_ll_nr],
color='#f78166', s=80, label='MLE optimum')
fig2.colorbar(surf, ax=ax2, shrink=0.5, aspect=10)
ax2.legend()
plt.tight_layout()
plt.show()

# ----------------------------------------------------------
# 8. Plot 3: 3D predicted-probability surface over feature space
# ----------------------------------------------------------
x1_range = np.linspace(size_std.min() - 1, size_std.max() + 1, 60)
x2_range = np.linspace(irregularity_std.min() - 1, irregularity_std.max() + 1, 60)
X1, X2 = np.meshgrid(x1_range, x2_range)

Z_input = np.column_stack([np.ones(X1.size), X1.ravel(), X2.ravel()])
Prob = sigmoid(Z_input @ beta_nr).reshape(X1.shape)

fig3 = plt.figure(figsize=(10, 8), facecolor=DARK_BG)
ax3 = fig3.add_subplot(111, projection='3d')
style_3d_axis(ax3)

ax3.plot_surface(X1, X2, Prob, cmap='viridis', alpha=0.75,
linewidth=0, antialiased=True)
ax3.scatter(size_std[y == 0], irregularity_std[y == 0],
np.zeros(np.sum(y == 0)), color='#58a6ff', s=15, label='Benign (0)')
ax3.scatter(size_std[y == 1], irregularity_std[y == 1],
np.ones(np.sum(y == 1)), color='#f78166', s=15, label='Malignant (1)')
ax3.set_xlabel('Tumor Size (standardized)', fontsize=10)
ax3.set_ylabel('Irregularity (standardized)', fontsize=10)
ax3.set_zlabel('P(Malignant)', fontsize=10)
ax3.set_title('Fitted Logistic Regression Probability Surface', fontsize=14)
ax3.legend()
plt.tight_layout()
plt.show()

# ----------------------------------------------------------
# 9. Plot 4: 2D decision boundary
# ----------------------------------------------------------
fig4, ax4 = plt.subplots(figsize=(9, 7), facecolor=DARK_BG)
ax4.set_facecolor(DARK_BG)

xx1, xx2 = np.meshgrid(np.linspace(size_std.min() - 1, size_std.max() + 1, 200),
np.linspace(irregularity_std.min() - 1, irregularity_std.max() + 1, 200))
grid = np.column_stack([np.ones(xx1.size), xx1.ravel(), xx2.ravel()])
probs = sigmoid(grid @ beta_nr).reshape(xx1.shape)

ax4.contourf(xx1, xx2, probs, levels=25, cmap='coolwarm', alpha=0.6)
ax4.contour(xx1, xx2, probs, levels=[0.5], colors='white', linewidths=2)
ax4.scatter(size_std[y == 0], irregularity_std[y == 0],
color='#58a6ff', edgecolor='white', s=30, label='Benign (0)')
ax4.scatter(size_std[y == 1], irregularity_std[y == 1],
color='#f78166', edgecolor='white', s=30, label='Malignant (1)')
ax4.set_xlabel('Tumor Size (standardized)', fontsize=12)
ax4.set_ylabel('Irregularity (standardized)', fontsize=12)
ax4.set_title('Decision Boundary at P = 0.5', fontsize=14)
ax4.legend(fontsize=11)
plt.tight_layout()
plt.show()

Code Walkthrough

Section 1 — Synthetic data generation. We create two clusters of points: “benign” tumors centered around small size/low irregularity, and “malignant” tumors centered around large size/high irregularity, each with Gaussian noise. This mimics a realistic, mildly overlapping medical classification scenario. Features are standardized (zero mean, unit variance) — this is important because it keeps the log-likelihood surface well-conditioned and prevents gradient ascent from oscillating or diverging.

Section 2 — Core math functions. sigmoid() implements $\sigma(z)$ with clipping to avoid floating-point overflow for large $|z|$. log_likelihood() implements $\ell(\boldsymbol{\beta})$ directly from the formula above, with a small epsilon added inside the logarithms to avoid $\log(0)$. gradient() computes $\mathbf{X}^\top(\mathbf{y}-\boldsymbol{\sigma})$, and hessian() computes $-\mathbf{X}^\top \mathbf{W} \mathbf{X}$ using broadcasting (X.T * W) instead of constructing a full diagonal matrix — this avoids an $O(n^2)$ memory allocation and is much faster for larger datasets.

Section 3 — Gradient ascent. This is the “naive” baseline: at every iteration we take a small step in the direction of the gradient. It’s simple but slow — it needs on the order of 1,000+ iterations to approach the optimum, and the step size (lr) has to be tuned carefully; too large and it diverges, too small and convergence takes forever.

Section 4 — Newton-Raphson (IRLS), the fast version. Instead of a fixed-size step, this method rescales the gradient by the inverse curvature (the Hessian), effectively taking a near-optimal step size in every direction automatically. Notice we use np.linalg.solve(H, grad) rather than explicitly computing np.linalg.inv(H) — solving the linear system directly is both faster and numerically more stable than inverting the Hessian. This method typically converges in under 10 iterations, versus 1,500 for gradient ascent — several orders of magnitude fewer computations for essentially the same solution.

Section 5 — Running both optimizers and printing diagnostics. We print the number of iterations, final coefficients, and final log-likelihood for both methods so we can directly compare their efficiency and confirm they converge to (nearly) the same optimum.

Section 6 — Convergence plot. A log-scale x-axis plot showing how quickly the log-likelihood rises for each method — this is where the speed advantage of Newton-Raphson becomes visually obvious.

Section 7 — 3D log-likelihood landscape. We fix the intercept at its optimal value and sweep the two feature coefficients over a grid, computing the log-likelihood at every point. This surface is concave (a single smooth peak), which visually confirms why both optimizers are guaranteed to find the same global maximum — there are no local traps.

Section 8 — 3D probability surface. This shows the fitted sigmoid surface $\sigma(\mathbf{x}^\top\boldsymbol{\beta}^*)$ over the feature space, with the actual data points plotted at $z=0$ or $z=1$ depending on their true label. It’s a great way to see how the S-shaped sigmoid stretches across two dimensions to separate the classes.

Section 9 — 2D decision boundary. A more traditional visualization: the region where the predicted probability crosses 0.5, overlaid with the actual data points, showing the model’s final classification boundary.

===== Gradient Ascent =====
Iterations run     : 1500
Final coefficients : [0.85907155 7.73459307 5.20561006]
Final log-likelihood: -7.611593

===== Newton-Raphson (IRLS) =====
Iterations run     : 12
Final coefficients : [0.90303886 8.06329174 5.3454986 ]
Final log-likelihood: -7.603384

Understanding the Convergence Comparison

This plot is the clearest demonstration of why second-order methods matter. Gradient ascent needs a long, gradual climb — hundreds of tiny steps — to approach the log-likelihood peak, whereas Newton-Raphson essentially “sees” the curvature of the landscape and jumps almost directly to the top within a handful of iterations. For small datasets like ours this speed difference is a curiosity; for large-scale problems with many features, it can be the difference between seconds and hours of training time.

Understanding the Log-Likelihood Landscape

This is the objective function we’re maximizing, rendered as a 3D surface over the two feature coefficients. Its single smooth peak (rather than multiple bumps) reflects the mathematical fact that the logistic regression log-likelihood is concave — there’s exactly one maximum, and both of our optimizers are mathematically guaranteed to find it, just at very different speeds.

Understanding the Fitted Probability Surface

Here we see the S-shaped sigmoid function stretched across two input dimensions. Points sitting near $z=0$ (blue, benign) cluster where the surface is close to 0, and points near $z=1$ (orange, malignant) cluster where the surface approaches 1. The steep “cliff” running diagonally through the middle of the surface is exactly where the model is most uncertain — this cliff, viewed from directly above, is what produces the decision boundary in the next plot.

Understanding the Decision Boundary

The white contour line marks where the model’s predicted probability equals exactly 0.5 — everything on the orange side is classified malignant, everything on the blue side is classified benign. Because our two synthetic clusters are well-separated but not perfectly so, a handful of points naturally fall on the “wrong” side of the boundary, which is realistic and expected in any maximum-likelihood classifier fit to noisy data.

Summary

We formulated logistic regression as a log-likelihood maximization problem, derived its gradient and Hessian, and implemented two optimizers — plain gradient ascent and Newton-Raphson — from scratch using NumPy. Both converge to the same maximum-likelihood solution, but Newton-Raphson does so roughly 100x faster in terms of iteration count, thanks to its use of second-order curvature information. Visualizing the log-likelihood as a 3D surface makes the concavity of the optimization problem tangible, while the fitted probability surface and decision boundary translate the abstract parameter estimates back into an intuitive picture of how the model separates the two classes.