You have a model that predicts house prices. Some predictions are too high, some too low. You need one number that tells you how wrong the model is overall — so you can tweak each weight and watch the error change. By the end of this chapter you will have invented that number from scratch.
Imagine you're building a model that predicts house prices (in lakhs) for 5 different houses at a time. You want to compute the overall error of this model — a single number — so that you can try tweaking each individual weight and see how that weight impacts the overall error.
Here are the actual prices and your model's predictions:
| House | Actual | Predicted |
|---|---|---|
| 1 | 1.0 | 0.9 |
| 2 | 1.1 | 2.0 |
| 3 | 0.9 | 0.7 |
| 4 | 0.5 | 0.3 |
| 5 | 3.0 | 2.0 |
Your job: come up with a formula that gives one number representing how wrong the predictions are overall.
The simplest idea: for each house, compute predicted - actual, then add them all up.
Try it. What number do you get? Does that number accurately reflect how wrong the predictions are?
actual = [1.0, 1.1, 0.9, 0.5, 3.0]
predicted = [0.9, 2.0, 0.7, 0.3, 2.0]
Compute sum(p - a for a, p in zip(actual, predicted)). What do you get?
The result is close to zero — but every single prediction is wrong! The negative errors (under-predictions) cancel the positive errors (over-predictions). That's the problem.
The sum is -0.5, which looks tiny. But look at house 2: the prediction is off by 0.9, and house 5 is off by 1.0. The positive and negative errors cancel each other out. We need a formula where errors can't cancel.
actual = [1.0, 1.1, 0.9, 0.5, 3.0]
predicted = [0.9, 2.0, 0.7, 0.3, 2.0]
total_error = sum(p - a for a, p in zip(actual, predicted))
print(f"Sum of differences: {total_error}")
# Output: -0.5
# This is misleading — every prediction is wrong, but the sum is near zero.
The problem is clear: positive and negative differences cancel each other out.
Think of at least two different mathematical operations that would prevent this cancellation — operations that make every error contribute positively to the total, regardless of whether the prediction was too high or too low.
Idea 1:
Idea 2:
What operations turn a negative number into a positive one?
There are two classic choices: one that removes the sign, and one that makes everything positive by multiplying.
Idea 1 — Absolute value: Take |predicted - actual| for each house. Negatives
become positive, so nothing cancels.
Idea 2 — Squaring: Take (predicted - actual)² for each house. Any number
squared is positive, so nothing cancels.
These are exactly the two approaches the ML community uses!
What you just invented:
You independently arrived at the two most important loss functions in machine learning:
The entire field of regression uses one of these (or a combination). Let's formalise them.
Let's give proper names and formulas to your two ideas.
Mean Squared Error (MSE):
$$\text{MSE} = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2$$
Mean Absolute Error (MAE):
$$\text{MAE} = \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i|$$
Here $y_i$ is the actual value and $\hat{y}_i$ is the predicted value.
Below are implementations of both. Study them — you'll use them to compare behavior.
Compute MSE and MAE on the original house data. Then imagine house 5's prediction
goes wildly wrong — say predicted[4] = 100 instead of 2.0. Compute both metrics
again.
def compute_mse(actual, predicted):
n = len(actual)
return sum((a - p) ** 2 for a, p in zip(actual, predicted)) / n
def compute_mae(actual, predicted):
n = len(actual)
return sum(abs(a - p) for a, p in zip(actual, predicted)) / n
actual = [1.0, 1.1, 0.9, 0.5, 3.0]
predicted = [0.9, 2.0, 0.7, 0.3, 2.0]
# Normal case
print(f"MSE: {compute_mse(actual, predicted):.4f}")
print(f"MAE: {compute_mae(actual, predicted):.4f}")
Create a second predicted list with predicted_outlier = [0.9, 2.0, 0.7, 0.3, 100.0] and compute both metrics on it.
Squaring a large number makes it enormous. (3 - 100)² = 9409, while |3 - 100| = 97. That's why MSE explodes.
MSE jumps from 0.49 to 1882 — a 3800× increase. MAE goes from 0.56 to 20 — about 36×. MSE is far more sensitive to outliers because squaring amplifies large errors.
actual = [1.0, 1.1, 0.9, 0.5, 3.0]
predicted = [0.9, 2.0, 0.7, 0.3, 2.0]
print("Normal data:")
print(f" MSE: {compute_mse(actual, predicted):.4f}") # 0.488
print(f" MAE: {compute_mae(actual, predicted):.4f}") # 0.560
predicted_outlier = [0.9, 2.0, 0.7, 0.3, 100.0]
print("\nWith outlier (house 5 predicted=100):")
print(f" MSE: {compute_mse(actual, predicted_outlier):.4f}") # 1882.088
print(f" MAE: {compute_mae(actual, predicted_outlier):.4f}") # 19.960
You've just seen one drawback of MSE. Let's think more carefully:
MSE problem: When a single prediction is very wrong (an outlier), what happens to the MSE? Is this good or bad for training a model?
MAE — any issues? Think about the shape of |x| as a function. Is there
anything unusual about it at x = 0? (We'll investigate this in the next part.)
MSE drawback:
Possible MAE issue:
For MSE: squaring makes large errors dominate. A single outlier can make the model ignore all other data points to fix that one mistake.
For MAE: plot y = |x|. What does the graph look like at x = 0? Is it smooth or does it have a sharp corner?
MSE: Squaring amplifies large errors disproportionately. One outlier can dominate the entire loss, pulling the model toward that single point.
MAE: The function |x| has a sharp corner (a "kink") at x = 0. This will
turn out to be a problem when we try to compute gradients — which is essential for
training ML models.
Remember why we wanted a loss function in the first place? We said:
"We need to tweak each individual weight and see how that weight impacts the overall error."
That's exactly what differentiation does. The gradient of the loss with respect to a parameter tells you: if I nudge this parameter a tiny bit, how much does the loss change?
Our training algorithm (gradient descent) depends on this. So our loss function must be differentiable — it must have a well-defined gradient everywhere.
Let's check whether our two loss functions qualify.
To keep things simple, let's focus on a single prediction. Define:
$$\text{diff} = y - \hat{y}$$
Then our two per-element losses are:
We want to compute $\frac{dL}{d(\text{diff})}$ for each — how the loss changes as the prediction error changes.
For the squared loss $L = \text{diff}^2$, compute the gradient $\frac{dL}{d(\text{diff})}$ using the power rule.
Then verify your formula numerically: pick a few values of diff (e.g. -2, 0, 1, 3),
compute the gradient using finite differences
$\frac{L(\text{diff} + \epsilon) - L(\text{diff} - \epsilon)}{2\epsilon}$ with a small
$\epsilon$, and check that it matches your analytical formula.
def numerical_gradient(loss_fn, diff, eps=1e-6):
return (loss_fn(diff + eps) - loss_fn(diff - eps)) / (2 * eps)
def squared_loss(diff):
return diff ** 2
Power rule: $\frac{d}{dx}x^2 = 2x$. So the gradient of $\text{diff}^2$ is $2 \cdot \text{diff}$.
Verify:
for d in [-2, 0, 1, 3]:
analytical = 2 * d
numerical = numerical_gradient(squared_loss, d)
print(f"diff={d:+}: analytical={analytical:+.4f}, numerical={numerical:+.6f}")
The gradient of $\text{diff}^2$ is $2 \cdot \text{diff}$. Notice that when the error is large (e.g. diff=3), the gradient is also large (6). The gradient grows without bound as the error grows — this is why MSE reacts so strongly to outliers.
def squared_gradient(diff):
return 2 * diff
for d in [-2, -1, 0, 1, 3]:
analytical = squared_gradient(d)
numerical = numerical_gradient(squared_loss, d)
print(f"diff={d:+}: analytical={analytical:+.4f}, numerical={numerical:+.6f}")
Now for the absolute loss $L = |\text{diff}|$, compute the gradient $\frac{dL}{d(\text{diff})}$.
Think piecewise:
diff > 0, what is |diff|? What's its derivative?diff < 0, what is |diff|? What's its derivative?Then verify numerically for diff values: -2, -0.5, 0.5, 3.
def abs_loss(diff):
return abs(diff)
When diff > 0: |diff| = diff, so the derivative is +1. When diff < 0: |diff| = -diff, so the derivative is -1.
for d in [-2, -0.5, 0.5, 3]:
numerical = numerical_gradient(abs_loss, d)
print(f"diff={d:+}: numerical gradient = {numerical:+.6f}")
You should see +1 for positive diff, -1 for negative diff.
The gradient is +1 for positive diff, -1 for negative diff. Unlike the squared loss, the gradient doesn't grow with the error — it's always ±1 regardless of how big the error is. That's good for outliers, but there's a problem...
def abs_gradient(diff):
if diff > 0:
return 1
elif diff < 0:
return -1
else:
return None # undefined!
for d in [-2, -0.5, 0.5, 3]:
analytical = abs_gradient(d)
numerical = numerical_gradient(abs_loss, d)
print(f"diff={d:+.1f}: analytical={analytical:+}, numerical={numerical:+.6f}")
You found that the gradient of |diff| is +1 when diff > 0, and -1 when diff < 0.
But what is the gradient when diff = 0 exactly? Is it -1 or +1?
Try computing the numerical gradient at diff = 0. Then think about what the graph
of y = |x| looks like at x = 0. Is it smooth, or is there something unusual?
Gradient of |diff| at diff = 0:
What does the graph look like at that point?
At diff = 0, the function switches from -diff to +diff. The left side has slope -1, the right side has slope +1. There's no single tangent line — the function has a sharp corner.
The gradient is undefined at diff = 0. The function $|x|$ has a sharp corner (a "kink") at $x = 0$: approaching from the left the slope is -1, approaching from the right the slope is +1. There is no single well-defined tangent line.
In mathematical terms: $|x|$ is not differentiable at $x = 0$.
Why this matters:
Our training algorithm (gradient descent) works by computing the gradient of the loss and using it to update weights. If the gradient doesn't exist at some point, the algorithm breaks down.
MSE ($\text{diff}^2$): Differentiable everywhere. Gradient = $2 \cdot \text{diff}$ — smooth, well-defined, no problems. But the gradient grows without bound for large errors, making training unstable with outliers.
MAE ($|\text{diff}|$): Not differentiable at diff = 0. The sharp corner means gradient descent has no direction to follow when the prediction is exactly right.
So MSE has a problem with large errors, and MAE has a problem at zero error. Can we do better?
Here's our situation:
| Small errors | Large errors | |||
|---|---|---|---|---|
| Squared ($\text{diff}^2$) | Smooth, differentiable | Gradient explodes | ||
| Absolute ($ | \text{diff} | $) | Not differentiable at 0 | Gradient stays bounded (±1) |
What if we could use the squared loss for small errors (where it's well-behaved) and the absolute loss for large errors (where it stays bounded)?
Here's the simplest idea. Pick some threshold $d$ and define:
$$L(\text{diff}) = \begin{cases} \text{diff}^2 & \text{if } |\text{diff}| \le d \ |\text{diff}| & \text{if } |\text{diff}| > d \end{cases}$$
Try $d = 1$. Now compute:
Now compute the gradients at that same point:
What's the problem?
Value from squared formula at |diff|=1:
Value from absolute formula at |diff|=1:
Gradient from squared at diff=1:
Gradient from absolute at diff=1:
The problem:
Squared: $d^2 = 1^2 = 1$. Absolute: $|d| = 1$. They happen to be equal when $d = 1$, but the gradients are different: squared gives $2 \times 1 = 2$, absolute gives $1$.
Try $d = 2$: squared gives $4$, absolute gives $2$. Now even the values don't match! At the boundary, the function jumps — it's discontinuous. And the gradients never match (except by coincidence).
At $|\text{diff}| = d$:
The function has discontinuities in both value and gradient at the boundary. Gradient descent can't work with jumps — we need smoothness.
Plot the naive combined loss for $\text{diff}$ ranging from -3 to 3, using $d = 1$.
You should see a clear discontinuity (jump) at diff = 1 and diff = -1 where the two formulas switch.
Also plot the individual squared and absolute losses on the same graph for comparison.
import numpy as np
import matplotlib.pyplot as plt
diff = np.linspace(-3, 3, 1000)
d = 1 # threshold
squared = diff ** 2
absolute = np.abs(diff)
# Naive combination: squared when |diff| <= d, absolute otherwise
naive = np.where(np.abs(diff) <= d, diff ** 2, np.abs(diff))
plt.figure(figsize=(8, 5))
plt.plot(diff, squared, '--', alpha=0.4, label='Squared (diff²)')
plt.plot(diff, absolute, '--', alpha=0.4, label='Absolute (|diff|)')
plt.plot(diff, naive, 'k-', linewidth=2, label='Naive combination')
plt.axvline(d, color='red', linestyle=':', alpha=0.5)
plt.axvline(-d, color='red', linestyle=':', alpha=0.5)
plt.legend()
plt.xlabel('diff')
plt.ylabel('Loss')
plt.title('Naive combination — spot the jump at ±d')
plt.show()
With $d = 1$ the values happen to match (both give 1), but the slopes are visibly different — the squared curve arrives with a steep slope while the absolute line departs with slope 1. Try $d = 2$ to see a clear value jump too.
plt.figure(figsize=(8, 5))
plt.plot(diff, squared, '--', alpha=0.4, label='Squared (diff²)')
plt.plot(diff, absolute, '--', alpha=0.4, label='Absolute (|diff|)')
plt.plot(diff, naive, 'k-', linewidth=2, label='Naive combination')
plt.axvline(d, color='red', linestyle=':', alpha=0.5, label=f'±d = ±{d}')
plt.axvline(-d, color='red', linestyle=':', alpha=0.5)
plt.legend()
plt.xlabel('diff')
plt.ylabel('Loss')
plt.title('Naive combination — spot the jump at ±d')
plt.grid(True, alpha=0.3)
plt.show()
The problem is clear: at the switchover point $|\text{diff}| = d$, the squared and absolute formulas disagree in both value and gradient.
We can't change the squared part — it's what gives us smoothness near zero, which is the whole reason we want it. So let's keep $\text{diff}^2$ for $|\text{diff}| \le d$ and modify the absolute part to connect seamlessly.
Instead of plain $|\text{diff}|$, we'll use:
$$L_{\text{outer}}(\text{diff}) = A \cdot |\text{diff}| + B$$
We need to find values of $A$ and $B$ such that at the boundary $|\text{diff}| = d$, the two pieces agree in both value and gradient.
We keep $\text{diff}^2$ for $|\text{diff}| \le d$. For $|\text{diff}| > d$, we use $A \cdot |\text{diff}| + B$.
At the boundary $|\text{diff}| = d$, we need two conditions:
This gives you two equations and two unknowns ($A$ and $B$). Solve for $A$ and $B$ in terms of $d$.
Then write a Python function smooth_combined_loss(diff, d) using your values of
$A$ and $B$, and plot it. Verify there's no jump or kink at $\pm d$.
The gradient of $\text{diff}^2$ is $2 \cdot \text{diff}$. At diff = $d$, that's $2d$. The gradient of $A \cdot |\text{diff}| + B$ (for diff > 0) is just $A$. So the gradient condition gives: $A = 2d$.
Substitute $A = 2d$ into the value equation: $d^2 = 2d \cdot d + B$, so $B = d^2 - 2d^2 = -d^2$.
Your combined loss should be:
$$L = \begin{cases} \text{diff}^2 & |\text{diff}| \le d \ 2d \cdot |\text{diff}| - d^2 & |\text{diff}| > d \end{cases}$$
def smooth_combined_loss(diff, d):
if abs(diff) <= d:
return diff ** 2
else:
return 2 * d * abs(diff) - d ** 2
$A = 2d$, $B = -d^2$. The curve is now perfectly smooth at the transition point — no jump in value, no jump in gradient.
# From gradient matching: A = 2d
# From value matching: d² = 2d·d + B → B = -d²
def smooth_combined_loss(diff, d):
if abs(diff) <= d:
return diff ** 2
else:
return 2 * d * abs(diff) - d ** 2
# Verify at boundary: both sides should give the same value and gradient
d = 1
print(f"Inner side at diff=d: value = {d**2}, gradient = {2*d}")
print(f"Outer side at diff=d: value = {2*d*d - d**2}, gradient = {2*d}")
# Plot
diff_range = np.linspace(-3, 3, 1000)
loss_values = [smooth_combined_loss(x, d) for x in diff_range]
plt.figure(figsize=(8, 5))
plt.plot(diff_range, loss_values, 'k-', linewidth=2, label='Smooth combined loss')
plt.plot(diff_range, diff_range ** 2, '--', alpha=0.3, label='diff²')
plt.plot(diff_range, 2*d*np.abs(diff_range) - d**2, '--', alpha=0.3, label=f'2d|diff| - d²')
plt.axvline(d, color='red', linestyle=':', alpha=0.5)
plt.axvline(-d, color='red', linestyle=':', alpha=0.5)
plt.legend()
plt.xlabel('diff')
plt.ylabel('Loss')
plt.title('Smooth combined loss — no jump, no kink!')
plt.grid(True, alpha=0.3)
plt.show()
You just invented the Huber loss!
The standard formulation divides everything by 2 for convenience (so the squared part's gradient is $\text{diff}$ instead of $2 \cdot \text{diff}$):
$$L_{\delta}(\text{diff}) = \begin{cases} \frac{1}{2}\text{diff}^2 & |\text{diff}| \le \delta \ \delta \cdot |\text{diff}| - \frac{1}{2}\delta^2 & |\text{diff}| > \delta \end{cases}$$
This is mathematically identical to yours — just scaled by ½. It was published by
the statistician Peter Huber in 1964 and is built into every modern ML framework:
torch.nn.HuberLoss, tf.keras.losses.Huber, sklearn's HuberRegressor.
You derived it from scratch by asking three questions:
Let's implement the standard (÷2) Huber loss and see how it compares to MSE and MAE on real data.
Write a function huber_loss(y_true, y_pred, delta) that returns the average
Huber loss over all pairs. Use the standard formulation (with the ½ factor):
Test it:
huber_loss([5, 2, 7], [4.8, 2.5, 10], delta=1) # ≈ 0.8817
huber_loss([1, 2, 3], [1, 2, 3], delta=1) # 0.0
Loop over pairs, compute err = abs(y - yp), then apply the appropriate formula:
if err <= delta:
total += 0.5 * (y - yp) ** 2
else:
total += delta * err - 0.5 * delta ** 2
def huber_loss(y_true, y_pred, delta):
total = 0
for y, yp in zip(y_true, y_pred):
err = abs(y - yp)
if err <= delta:
total += 0.5 * (y - yp) ** 2
else:
total += delta * err - 0.5 * delta ** 2
return total / len(y_true)
print(huber_loss([5, 2, 7], [4.8, 2.5, 10], delta=1)) # ≈ 0.8817
print(huber_loss([1, 2, 3], [1, 2, 3], delta=1)) # 0.0
Plot all three loss functions — MSE, MAE, and Huber — for a single prediction as
diff varies from -5 to 5. Use delta = 1.
Verify visually:
import numpy as np
import matplotlib.pyplot as plt
diff = np.linspace(-5, 5, 1000)
delta = 1
mse = 0.5 * diff ** 2
mae = np.abs(diff)
huber = np.where(
np.abs(diff) <= delta,
0.5 * diff ** 2,
delta * np.abs(diff) - 0.5 * delta ** 2
)
plt.figure(figsize=(8, 5))
plt.plot(diff, mse, '--', label='MSE (½ diff²)')
plt.plot(diff, mae, '--', label='MAE (|diff|)')
plt.plot(diff, huber, 'k-', linewidth=2, label=f'Huber (δ={delta})')
plt.legend()
plt.xlabel('diff')
plt.ylabel('Loss')
plt.title('MSE vs MAE vs Huber Loss')
plt.grid(True, alpha=0.3)
plt.show()
The Huber curve hugs the MSE parabola near zero, then peels off and tracks the MAE line for large errors — smooth everywhere, no kinks, no explosions.
huber = np.where(
np.abs(diff) <= delta,
0.5 * diff ** 2,
delta * np.abs(diff) - 0.5 * delta ** 2
)
plt.figure(figsize=(8, 5))
plt.plot(diff, mse, '--', alpha=0.6, label='½ diff² (MSE)')
plt.plot(diff, mae, '--', alpha=0.6, label='|diff| (MAE)')
plt.plot(diff, huber, 'k-', linewidth=2, label=f'Huber (δ={delta})')
plt.axvline(delta, color='red', linestyle=':', alpha=0.4)
plt.axvline(-delta, color='red', linestyle=':', alpha=0.4)
plt.legend(fontsize=11)
plt.xlabel('diff', fontsize=12)
plt.ylabel('Loss', fontsize=12)
plt.title('MSE vs MAE vs Huber Loss', fontsize=13)
plt.grid(True, alpha=0.3)
plt.ylim(-0.5, 6)
plt.show()
Compute all three losses on the house price data — with and without the outlier.
actual = [1.0, 1.1, 0.9, 0.5, 3.0]
normal = [0.9, 2.0, 0.7, 0.3, 2.0]
outlier = [0.9, 2.0, 0.7, 0.3, 100.0]
Use delta = 1. Which loss function gives the most sensible numbers in both
scenarios?
for name, pred in [("Normal", normal), ("Outlier", outlier)]:
print(f"{name}:")
print(f" MSE: {compute_mse(actual, pred):.4f}")
print(f" MAE: {compute_mae(actual, pred):.4f}")
print(f" Huber: {huber_loss(actual, pred, delta=1):.4f}")
With the outlier, MSE explodes to ~1882 while Huber stays at ~19.8 — similar to MAE's 20.0 but with the advantage of being fully differentiable. On normal data, Huber behaves like MSE, giving smooth gradients near zero. Best of both worlds.
actual = [1.0, 1.1, 0.9, 0.5, 3.0]
normal = [0.9, 2.0, 0.7, 0.3, 2.0]
outlier = [0.9, 2.0, 0.7, 0.3, 100.0]
for name, pred in [("Normal", normal), ("Outlier", outlier)]:
print(f"{name} predictions:")
print(f" MSE: {compute_mse(actual, pred):.4f}")
print(f" MAE: {compute_mae(actual, pred):.4f}")
print(f" Huber: {huber_loss(actual, pred, delta=1):.4f}")
print()
Your Invention → Industry Name
| Exercise | What You Did | Industry Name |
|---|---|---|
| 1.1 | Discovered that plain differences cancel out | The need for a loss function |
| 1.2 | Found two ways to fix cancellation | Squared error / Absolute error |
| 2.1–2.2 | Compared MSE and MAE, found drawbacks | Bias–variance tradeoff of losses |
| 3.1 | Computed gradient of squared error | MSE gradient: 2·diff |
| 3.2 | Computed gradient of absolute error | MAE gradient: sign(diff) |
| 3.3 | Found MAE is not differentiable at 0 | Non-differentiability of L1 loss |
| 4.1–4.2 | Tried naive combination, spotted discontinuity | Piecewise loss design |
| 4.3 | Solved for A and B to make it smooth | Huber loss derivation |
| 5.1–5.3 | Implemented and compared all three | Huber loss (Huber, 1964) |
Going further: