Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate

Regression Metrics — MAE, RMSE, R²

When your output is a number not a class. MAE, RMSE, MAPE, R², and which metric to choose based on how you want to treat large errors.

22–28 min March 2026
Before any formula — what makes regression evaluation different?

A classification model is either right or wrong. A regression model is never exactly right — the question is how wrong, and in what direction does wrong hurt more?

DoorDash predicts delivery time as 32 minutes. The actual time is 41 minutes. The model was wrong by 9 minutes. Is that acceptable? That depends on what DoorDash promised the customer. If the app said "arrives in 32 minutes" and it took 41, the customer is angry. The cost of underestimating is higher than the cost of overestimating.

Now imagine one prediction was wrong by 9 minutes and another was wrong by 45 minutes. Are those two errors equally bad? For DoorDash, 45 minutes late might trigger a refund, damage the restaurant's rating, and lose the customer permanently. That one large error is catastrophically worse than five 9-minute errors. The metric you choose determines whether your model optimises to minimise all errors equally or to specifically avoid large ones.

This is the core decision in regression evaluation: how do you want to penalise large errors?MAE treats all errors proportionally. RMSE squares the errors — large errors get penalised much more heavily. MAPE expresses error as a percentage — useful when the scale of the target varies. R² tells you how much better the model is than a naive baseline.

🧠 Analogy — read this first

A basketball commentator says "this team needs 12 points a quarter to win." The team scores 10, 11, 13, 9, 12, 8 — never exactly 12. MAE asks: how far off was each quarter on average? Answer: about 1.5 points. RMSE asks the same but doubles down on the 8-point quarter (4 under) — that squared miss from target hurts more than two smaller misses. MAPE asks: what percentage of the target was each miss?

Choose MAE when all errors cost equally — late by 5 minutes is 5× worse than late by 1 minute, nothing more. Choose RMSE when catastrophic errors cost disproportionately — one 45-minute delay is far worse than nine 5-minute delays.

The complete metric toolkit

Four metrics — formulas, intuitions, and when each is right

Regression metrics — same predictions, different perspectives
PredictionActualError|Error|Error²|Error|/Actual
3241-998122.0%
45423397.1%
2829-1113.4%
5297-4545202546.4%
38353398.6%
12.2 → MAE425 → MSE17.5% → MAPE

The 45-minute error (row 4) contributes 2025 to MSE — 25× more than the 9-minute error (81). In MAE it contributes 45 — only 5× more. RMSE = √(mean(MSE)) = √425 = 20.6 min. The one outlier dramatically inflates RMSE while MAE stays at 12.2.

MAE — Mean Absolute Errormean(|y − ŷ|)

Units: Same units as target

Interpret: "On average the model is off by X minutes."

Penalises: All errors proportionally. A 10-min error is 2× worse than a 5-min error.

Use when: When all error magnitudes cost equally. Easy to explain to stakeholders.

Avoid when: When large errors are disproportionately costly.

RMSE — Root Mean Squared Error√mean((y − ŷ)²)

Units: Same units as target

Interpret: "Typical error magnitude, with large errors weighted more heavily."

Penalises: Large errors quadratically. A 10-min error is 4× worse than a 5-min error.

Use when: When catastrophic errors must be avoided. Standard in competitions.

Avoid when: When outliers are present and acceptable — RMSE will be dominated by them.

MAPE — Mean Absolute Percentage Errormean(|y − ŷ| / |y|) × 100

Units: Percentage — scale-independent

Interpret: "On average the model is off by X% of the actual value."

Penalises: Relative errors. Being off by 5 on a target of 10 is worse than off by 5 on a target of 100.

Use when: Comparing models across targets of different scales. Demand forecasting.

Avoid when: When true values are zero or near-zero — MAPE explodes. Not symmetric.

R² — Coefficient of Determination1 − SS_res / SS_tot

Units: Dimensionless (0 to 1, can be negative)

Interpret: "The model explains X% of the variance in the target."

Penalises: Relative to the baseline of predicting the mean.

Use when: Quick sanity check. Comparing models on same dataset. R²=0.87 = 87% variance explained.

Avoid when: Comparing across datasets with different target variance. Can be misleading.

python
import numpy as np
from sklearn.metrics import (mean_absolute_error,
                              mean_squared_error,
                              mean_absolute_percentage_error,
                              r2_score)

# ── Compute all four metrics from scratch ─────────────────────────────
y_true = np.array([41, 42, 29, 97, 35], dtype=float)
y_pred = np.array([32, 45, 28, 52, 38], dtype=float)

errors     = y_true - y_pred
abs_errors = np.abs(errors)

# MAE
mae_manual = abs_errors.mean()
mae_sklearn = mean_absolute_error(y_true, y_pred)

# MSE and RMSE
mse_manual  = (errors ** 2).mean()
rmse_manual = np.sqrt(mse_manual)
rmse_sklearn = np.sqrt(mean_squared_error(y_true, y_pred))

# MAPE
mape_manual  = (abs_errors / np.abs(y_true)).mean() * 100
mape_sklearn = mean_absolute_percentage_error(y_true, y_pred) * 100

# R²
ss_res = (errors ** 2).sum()
ss_tot = ((y_true - y_true.mean()) ** 2).sum()
r2_manual  = 1 - ss_res / ss_tot
r2_sklearn = r2_score(y_true, y_pred)

print("Manual vs sklearn verification:")
print(f"  MAE:   {mae_manual:.4f}  sklearn: {mae_sklearn:.4f}  match: {np.isclose(mae_manual, mae_sklearn)}")
print(f"  RMSE:  {rmse_manual:.4f}  sklearn: {rmse_sklearn:.4f}  match: {np.isclose(rmse_manual, rmse_sklearn)}")
print(f"  MAPE:  {mape_manual:.4f}%  sklearn: {mape_sklearn:.4f}%")
print(f"  R²:    {r2_manual:.4f}  sklearn: {r2_sklearn:.4f}  match: {np.isclose(r2_manual, r2_sklearn)}")

print(f"
Per-sample contribution to each metric:")
print(f"{'Error':>8} {'|error|':>8} {'error²':>8} {'% error':>8}")
print("─" * 38)
for e, ae, y in zip(errors, abs_errors, y_true):
    pct = abs(e) / abs(y) * 100
    print(f"  {e:>6.0f}  {ae:>8.0f}  {ae**2:>8.0f}  {pct:>7.1f}%")
print(f"  {'Mean':>6}  {mae_manual:>8.1f}  {mse_manual:>8.0f}  {mape_manual:>7.1f}%")
print(f"  {'':>6}  {'↑MAE':>8}  {'√→RMSE':>8}")
Understanding R²

R² — what it measures, why it can go negative, and when it misleads

R² measures how much better your model is than the simplest possible baseline: always predicting the mean. If someone asked you to predict DoorDash delivery times with no model at all, your best guess would be the historical mean — about 36 minutes for everything. R² = 0 means your model is exactly as good as that naive guess. R² = 0.87 means your model explains 87% of the variance that the mean baseline cannot explain. R² = 1 is a perfect model.

R² can go below zero. This happens when your model is worse than just predicting the mean — its predictions are so bad they increase the total squared error beyond what a constant prediction would give. A negative R² is a signal that something is severely wrong: wrong features, data leakage in reverse, or a completely broken pipeline.

R² decomposition — what SS_res and SS_tot represent
R² = 1 − SS_res / SS_tot
SS_tot = Σ(yᵢ − ȳ)² ← total variance in the data (baseline error)
SS_res = Σ(yᵢ − ŷᵢ)² ← residual variance after model (model error)
R² = 1: SS_res=0 → perfect predictions
R² = 0: SS_res=SS_tot → no better than the mean
< 0: SS_res > SS_tot → worse than the mean
python
import numpy as np
from sklearn.metrics import r2_score
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 2000
distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep])
y = delivery
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

# ── R² for different models ───────────────────────────────────────────
models = {
    'Always predict mean (baseline)': None,
    'Linear Regression':              LinearRegression(),
    'Gradient Boosting':              GradientBoostingRegressor(
                                          n_estimators=200, random_state=42),
    'Shuffled labels (broken)':       'shuffled',
}

print(f"R² comparison — DoorDash delivery time:")
print(f"{'Model':<35} {'R²':>8} {'MAE':>8} {'RMSE':>8}")
print("─" * 64)

for name, model in models.items():
    if model is None:
        y_pred = np.full_like(y_te, y_tr.mean())
    elif model == 'shuffled':
        y_pred = np.random.permutation(y_te)   # random predictions
    else:
        model.fit(X_tr, y_tr)
        y_pred = model.predict(X_te)

    r2   = r2_score(y_te, y_pred)
    mae  = np.mean(np.abs(y_te - y_pred))
    rmse = np.sqrt(np.mean((y_te - y_pred)**2))
    flag = '← always predict mean' if model is None else            '← worse than baseline!' if r2 < 0 else ''
    print(f"  {name:<33}  {r2:>8.4f}  {mae:>8.4f}  {rmse:>8.4f}  {flag}")

# ── Adjusted R² — penalises adding useless features ───────────────────
# Standard R² always increases when you add more features (even noise)
# Adjusted R² penalises extra features that do not improve the model
def adjusted_r2(r2, n, p):
    """r2: R², n: samples, p: number of features"""
    return 1 - (1 - r2) * (n - 1) / (n - p - 1)

for n_noise in [0, 5, 10, 20]:
    X_noise = np.hstack([X_tr, np.random.randn(len(X_tr), n_noise)])
    X_te_n  = np.hstack([X_te, np.random.randn(len(X_te), n_noise)])
    lr = LinearRegression().fit(X_noise, y_tr)
    y_pred_n = lr.predict(X_te_n)
    r2_val  = r2_score(y_te, y_pred_n)
    adj_r2  = adjusted_r2(r2_val, len(X_te_n), X_noise.shape[1])
    n_feat  = 3 + n_noise
    print(f"  {n_feat} features ({n_noise} noise): R²={r2_val:.4f}  Adj R²={adj_r2:.4f}"
          f"  {'← noise inflated R²' if n_noise > 0 else ''}")
Making the decision

Which metric to use — a decision framework

The right metric is determined by the business cost structure of your errors, not by convention. Before picking a metric, answer two questions: are large errors disproportionately costly? And does the scale of the target vary across predictions?

Metric selection decision tree
Q1: Are large errors disproportionately costly?
Yes → Use RMSE — it penalises large errors quadratically
No → Use MAE — it treats all errors proportionally

Example: DoorDash: one 45-min delay triggers a refund (costly) → RMSE. Stock price: all errors equally bad → MAE.

Q2: Do targets vary in scale across predictions?
Yes → Use MAPE — percentage error is scale-independent
No → Use MAE or RMSE — absolute errors are comparable

Example: Demand forecasting: selling 1000 units vs 10 units — 5-unit error means different things. Use MAPE.

Q3: Do you need a relative performance number?
Yes → Report R² alongside MAE/RMSE for context
No → Report MAE or RMSE in the target units

Example: Report to stakeholders: "MAE = 4.2 minutes (R² = 0.87)" gives both absolute and relative context.

python
import numpy as np
from sklearn.metrics import (mean_absolute_error, mean_squared_error,
                              mean_absolute_percentage_error, r2_score)
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import cross_validate, KFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 3000
distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)
X = np.column_stack([distance, traffic, prep])
y = delivery

pipeline = Pipeline([
    ('sc', StandardScaler()),
    ('m',  GradientBoostingRegressor(n_estimators=200, learning_rate=0.1,
                                      max_depth=3, random_state=42)),
])

# ── Always compare against a baseline ─────────────────────────────────
from sklearn.model_selection import train_test_split
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
pipeline.fit(X_tr, y_tr)
y_pred = pipeline.predict(X_te)

# Baselines
y_mean   = np.full_like(y_te, y_tr.mean())
y_median = np.full_like(y_te, np.median(y_tr))

print("Model vs baselines — DoorDash delivery time:")
print(f"{'Model':<25} {'MAE':>8} {'RMSE':>8} {'MAPE%':>8} {'R²':>8}")
print("─" * 62)

for label, pred in [
    ('Always predict mean',   y_mean),
    ('Always predict median', y_median),
    ('Gradient Boosting',     y_pred),
]:
    mae  = mean_absolute_error(y_te, pred)
    rmse = np.sqrt(mean_squared_error(y_te, pred))
    mape = mean_absolute_percentage_error(y_te, pred) * 100
    r2   = r2_score(y_te, pred)
    print(f"  {label:<23}  {mae:>8.4f}  {rmse:>8.4f}  {mape:>8.2f}  {r2:>8.4f}")

# ── Cross-validate multiple metrics at once ────────────────────────────
def rmse_scorer(est, X, y):
    return -np.sqrt(mean_squared_error(y, est.predict(X)))

from sklearn.metrics import make_scorer
cv_results = cross_validate(
    pipeline, X, y,
    cv=KFold(5, shuffle=True, random_state=42),
    scoring={
        'mae':  make_scorer(mean_absolute_error, greater_is_better=False),
        'rmse': make_scorer(rmse_scorer, greater_is_better=False),
        'r2':   'r2',
        'mape': make_scorer(mean_absolute_percentage_error,
                             greater_is_better=False),
    },
    return_train_score=False,
)

print(f"
5-fold CV across all metrics:")
print(f"  MAE:   {-cv_results['test_mae'].mean():.4f} ± {cv_results['test_mae'].std():.4f} min")
print(f"  RMSE:  {-cv_results['test_rmse'].mean():.4f} ± {cv_results['test_rmse'].std():.4f} min")
print(f"  MAPE:  {-cv_results['test_mape'].mean()*100:.2f}% ± {cv_results['test_mape'].std()*100:.2f}%")
print(f"  R²:    {cv_results['test_r2'].mean():.4f} ± {cv_results['test_r2'].std():.4f}")

# ── RMSE vs MAE ratio reveals outlier presence ─────────────────────────
rmse_val = -cv_results['test_rmse'].mean()
mae_val  = -cv_results['test_mae'].mean()
ratio    = rmse_val / mae_val
print(f"
RMSE / MAE ratio: {ratio:.2f}")
print("  Ratio ≈ 1.0: errors are uniform, no major outliers")
print("  Ratio > 2.0: large outlier errors present — investigate them")
Beyond the summary number

Residual analysis — where is the model systematically wrong?

A single MAE number hides a lot. A model with MAE = 4.2 minutes might be consistently accurate for short deliveries but systematically wrong for long-distance orders. The aggregate metric looks fine while a whole segment of customers is getting bad predictions. Residual analysis reveals these systematic patterns.

python
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 3000
distance = np.abs(np.random.normal(4.0, 2.0, n)).clip(0.5, 15)
traffic  = np.random.randint(1, 11, n).astype(float)
prep     = np.abs(np.random.normal(15, 5, n)).clip(5, 35)
delivery = (8.6 + 7.3*distance + 0.8*prep + 1.5*traffic
            + np.random.normal(0, 4, n)).clip(10, 120)

X = np.column_stack([distance, traffic, prep])
y = delivery
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
pipeline = Pipeline([
    ('sc', StandardScaler()),
    ('m',  GradientBoostingRegressor(n_estimators=200, random_state=42)),
])
pipeline.fit(X_tr, y_tr)
y_pred = pipeline.predict(X_te)

residuals = y_te - y_pred   # positive = underestimate, negative = overestimate

print(f"Overall MAE: {mean_absolute_error(y_te, y_pred):.4f} min")

# ── Are residuals biased? ──────────────────────────────────────────────
mean_residual = residuals.mean()
print(f"
Mean residual: {mean_residual:+.4f} min")
print(f"  {'← model systematically underestimates' if mean_residual > 0.5 else '← model systematically overestimates' if mean_residual < -0.5 else '← no systematic bias'}")

# ── Error by delivery time bucket ─────────────────────────────────────
print(f"
MAE by delivery time bucket:")
for low, high in [(10,20),(20,30),(30,45),(45,60),(60,120)]:
    mask = (y_te >= low) & (y_te < high)
    if mask.sum() > 10:
        mae_bucket  = mean_absolute_error(y_te[mask], y_pred[mask])
        bias_bucket = residuals[mask].mean()
        print(f"  {low:2d}–{high:3d} min  n={mask.sum():4d}  "
              f"MAE={mae_bucket:.2f}  bias={bias_bucket:+.2f}")

# ── Error by distance bucket ──────────────────────────────────────────
dist_te = X_te[:, 0]   # distance feature in test set
print(f"
MAE by delivery distance:")
for low, high in [(0,2),(2,4),(4,7),(7,10),(10,15)]:
    mask = (dist_te >= low) & (dist_te < high)
    if mask.sum() > 10:
        mae_d = mean_absolute_error(y_te[mask], y_pred[mask])
        print(f"  {low:.0f}–{high:.0f} km   n={mask.sum():4d}  MAE={mae_d:.2f} min")

# ── Largest errors — what went wrong? ─────────────────────────────────
worst_idx   = np.argsort(np.abs(residuals))[-5:][::-1]
print(f"
Top 5 worst predictions:")
print(f"  {'Actual':>8} {'Predicted':>10} {'Error':>8} {'Dist':>6} {'Traffic':>8} {'Prep':>6}")
print("  " + "─" * 52)
for idx in worst_idx:
    print(f"  {y_te[idx]:>8.1f} {y_pred[idx]:>10.1f} {residuals[idx]:>+8.1f} "
          f"{X_te[idx,0]:>6.1f}  {X_te[idx,1]:>8.0f}  {X_te[idx,2]:>6.0f}")
Errors you will hit

Every common regression metric mistake — explained and fixed

R² is negative — model is worse than predicting the mean
Why it happens

Three common causes: the model was trained on different data than it is being evaluated on (wrong split, data leakage in reverse), the features have no relationship to the target on the test set, or the model was fit with the wrong target (e.g. predicting log(y) but evaluating on y). Negative R² means SS_res > SS_tot — the model's errors are larger than if you had just predicted the mean for everything.

Fix

Check that training and test data come from the same distribution. Print y_train.mean() and y_test.mean() — if very different, the split is wrong. Print model.predict(X_test)[:5] and y_test[:5] — if predictions are in a completely different range, the target was transformed during training but not reversed at evaluation. Always un-transform predictions before computing metrics.

MAPE is infinity or extremely large (1000%+)
Why it happens

One or more true values in y_true are zero or very close to zero. MAPE divides by y_true — division by zero produces infinity which propagates through the mean. Even a single zero target makes MAPE meaningless for the entire evaluation.

Fix

Never use MAPE when targets can be zero. Use MAE or RMSE instead. If zero targets are rare edge cases, filter them: mask = y_true > 0; mape = mean_absolute_percentage_error(y_true[mask], y_pred[mask]). Consider symmetric MAPE (sMAPE) which divides by (|y_true| + |y_pred|)/2 — defined even when y_true=0 though it has other issues.

RMSE looks terrible but the model is actually useful in production
Why it happens

RMSE is dominated by a small number of very large errors — outliers in the test set. Five predictions off by 1 minute each and one prediction off by 50 minutes gives RMSE ≈ 20 minutes, making the model look terrible even though 5 out of 6 predictions are nearly perfect. The summary metric hides the distribution of errors.

Fix

Always inspect the error distribution alongside RMSE. Plot a histogram of residuals. Compute the 50th, 90th, and 95th percentile of |residuals|: np.percentile(np.abs(residuals), [50, 90, 95]). Report 'MAE = 3.2 min, 90th percentile error = 8.1 min' — much more informative than a single RMSE of 12. Investigate the large errors separately — they often reveal a specific failure mode.

R² is high (0.92) but MAE is also high — stakeholders are confused
Why it happens

R² and MAE measure fundamentally different things. R²=0.92 means the model explains 92% of the variance — it is highly correlated with the target. But if the target has very high variance (delivery times ranging from 10 to 120 minutes), 92% explained variance still leaves 8% unexplained, which in absolute terms could be 8 minutes of MAE. High R² does not mean small absolute errors.

Fix

Always report metrics in the target's units (MAE, RMSE in minutes) alongside R². Stakeholders understand 'off by 4.2 minutes on average' better than 'R²=0.87'. Use R² for comparing models on the same dataset and for communicating relative improvement. Use MAE/RMSE for communicating operational accuracy.

What this looks like at work

Two production regressors, two different error philosophies

The metric choice for a regression model is written into the design doc before training starts, driven by what a wrong prediction actually costs downstream — not by whichever metric is easiest to compute or looks best in a demo. Two models that both output a plain number, evaluated with completely different philosophies, make this concrete.

DoorDash delivery-time prediction — the tail matters more than the average

The business promise shown to the customer is not "we are off by four minutes on average" — it is "your order arrives within the estimated window most of the time." The team reports MAE as the headline number because it is easy to explain, but the metric that actually gates a launch is a hit-rate against a threshold: the percentage of deliveries within the promised window. RMSE gets tracked alongside MAE specifically because a widening gap between the two signals that a small number of deliveries are going badly wrong — the exact failure mode that triggers refunds and one-star reviews, even while the average error still looks fine.

Price / valuation prediction — the scale of the error is the whole story

A pricing model — a marketplace listing price, a real-estate valuation, an ad-auction bid estimate — spans items worth ten dollars and items worth a hundred thousand dollars in the same training set. An absolute error of ten dollars means something completely different depending on which item it lands on, so the team reports MAPE or, more often, a revenue-weighted percentage error rather than plain MAE. Plain MAPE has its own trap here: it treats a ten-dollar error on a fifty-dollar item the same as a ten-dollar error on a five-thousand-dollar item, so teams weight the error by the actual transaction value, because a five percent error on a large transaction costs far more than a five percent error on a small one.

The metric you evaluate with often becomes the loss you train with

Once a team settles on what "good" means, that definition frequently gets built directly into training, not just left for evaluation afterward. A delivery-time model whose real business metric is a percentile hit-rate is sometimes trained with quantile loss aimed directly at the ninetieth percentile, rather than the mean-squared-error loss that comes with sklearn's default regressor — training the model to be precisely accurate at the percentile that actually gates the SLA, instead of hoping that minimising average error happens to also fix the tail. A pricing model that cares about large errors on high-value items but does not want a handful of outliers to dominate training entirely might use a Huber loss, which behaves like squared error for small residuals and like absolute error for large ones — a training-time compromise that mirrors the same MAE-versus-RMSE tradeoff this module covers for evaluation.

python
import numpy as np

# ── Delivery-time model: an SLA hit-rate, not just MAE ─────────────────
# The business promise is "arrives within the estimate," which is a
# threshold-based hit-rate, not an average.
def delivery_sla_report(y_true, y_pred, sla_minutes=10):
    abs_error   = np.abs(y_true - y_pred)
    within_sla  = (abs_error <= sla_minutes).mean()
    p90_error   = np.percentile(abs_error, 90)
    return {
        'mae': abs_error.mean(),
        'within_sla_pct': within_sla * 100,
        'p90_error_min': p90_error,
    }

# ── Price model: revenue-weighted error, not plain MAPE ────────────────
# A cheap item's percentage error should not carry the same weight
# as an expensive item's percentage error in the business's eyes.
def price_wape_report(y_true, y_pred):
    abs_error  = np.abs(y_true - y_pred)
    wape       = abs_error.sum() / y_true.sum() * 100    # weighted by actual value
    plain_mape = (abs_error / y_true).mean() * 100        # unweighted, for comparison
    return {'wape_pct': wape, 'plain_mape_pct': plain_mape}

np.random.seed(42)
delivery_actual = np.random.normal(32, 8, 5000).clip(10, 90)
delivery_pred   = delivery_actual + np.random.normal(0, 4, 5000)
print("Delivery-time SLA report:")
for k, v in delivery_sla_report(delivery_actual, delivery_pred).items():
    print(f"  {k:<18}: {v:.2f}")

price_actual = np.concatenate([
    np.random.uniform(10, 200, 4000),        # everyday items
    np.random.uniform(5000, 100_000, 200),    # high-value items
])
price_pred = price_actual * np.random.normal(1.0, 0.06, len(price_actual))
print("\nPrice model report:")
for k, v in price_wape_report(price_actual, price_pred).items():
    print(f"  {k:<18}: {v:.2f}")
print("\nNotice: WAPE and plain MAPE diverge whenever error rates differ")
print("between cheap, high-volume items and rare, high-value ones.")
Misconceptions

Five things people get wrong about regression metrics

Myth: RMSE is the 'better' metric because competitions and papers default to it

RMSE's quadratic penalty on large errors is a modelling choice, not a universal improvement — it only makes sense when large errors genuinely cost more than proportionally, like DoorDash's 45-minute delay triggering a refund. If your business cost is truly linear in the size of the error — being off by 10 minutes is exactly twice as bad as being off by 5, no more — MAE matches that cost structure honestly, and it is also far more robust to a handful of outliers dominating the reported number. Defaulting to RMSE because it's the convention, without checking whether your error costs are actually quadratic, means optimising and reporting against the wrong objective.

Myth: A high R² means the model's predictions are accurate in absolute terms

R² is relative to the variance of the target, not to any absolute error tolerance. A model with R²=0.92 on delivery times ranging from 10 to 120 minutes is explaining 92% of a large variance — but the remaining 8% unexplained can still translate into an MAE of 8 minutes, which may be operationally unacceptable even though the R² number looks excellent. This module's own guidance is explicit about this: report MAE or RMSE in the target's real units alongside R², because R² alone tells you nothing about whether the typical error is 30 seconds or 30 minutes.

Myth: RMSE and MAE values are directly comparable across different targets or datasets

Being in the same units does not mean being on the same scale. An RMSE of 5 minutes for delivery-time predictions is not comparable to an RMSE of 5 dollars for price predictions, and even within the same problem, an RMSE of 5 minutes on a dataset averaging 30-minute deliveries is a much larger relative error than an RMSE of 5 minutes on a dataset averaging 90-minute deliveries. RMSE and MAE are scale-dependent by construction — comparing them meaningfully across datasets or targets requires normalising first, whether that's dividing by the mean, reporting MAPE instead, or using a normalised RMSE (RMSE / range or RMSE / mean).

Myth: A low overall error metric means the model is accurate for everyone

An aggregate MAE or RMSE is an average across every prediction, and averages hide systematic bias by construction. A model can post an excellent overall MAE of 4.2 minutes while being consistently 15 minutes late specifically for long-distance orders, or systematically biased for one customer segment — the aggregate number simply blends the good predictions with the bad ones. This is exactly why this module's residual analysis section exists: checking the mean residual, MAE by distance bucket, and MAE by delivery-time bucket separately is the only way to catch a model that looks fine in aggregate but is quietly failing a subgroup that never shows up in the headline metric.

Myth: MAPE is a safe default because it's scale-independent and intuitive

MAPE divides by the actual value, so it breaks down — sometimes to infinity — whenever true values are zero or very close to zero, which is common in demand forecasting for low-volume products. It is also asymmetric in a way that's easy to miss: a prediction of twice the actual value produces a 100% error, but a prediction of half the actual value is capped at a 50% error, so MAPE structurally punishes overestimates more harshly than underestimates of the same relative size. It is genuinely useful for comparing errors across targets of very different scales, but "scale-independent" is not the same as "safe to use everywhere" — it should be avoided whenever the target can be zero or near zero.

Interview prep

Regression metrics — 5 questions interviewers actually ask

Q1 — When would you choose MAE over RMSE, and vice versa?

The decision should follow the actual cost structure of your errors, not convention. Choose MAE when every unit of error costs proportionally the same — being off by 10 is exactly twice as bad as being off by 5, nothing more — and when you want a metric that isn't dominated by a handful of outliers. Choose RMSE when large errors are disproportionately costly in the real world, because squaring the error before averaging means a 10-minute miss contributes 4× more than a 5-minute miss, not 2×. A good answer also mentions checking the RMSE/MAE ratio in practice: a ratio near 1.0 means errors are fairly uniform and either metric tells a similar story; a ratio above 2.0 signals a few large outliers are inflating RMSE and worth investigating directly.

Q2 — Your model has R² = 0.91. Is that good?

It depends on three things I'd check before answering. First, compared to what baseline — R²=0.91 sounds strong, but if a naive model (always predict the mean) already gets R²=0.85 on this target because the target itself is easy to predict, the real lift from the model is much smaller than 0.91 suggests. Second, what does that translate to in absolute error — R²=0.91 on a target with huge variance can still leave a large MAE in real units, which matters more operationally than the R² number itself. Third, is it measured on a held-out set with the same distribution as production — R² computed on training data or on a leaked split is not trustworthy at all. I wouldn't call any single R² value "good" without that context.

Q3 — What does it mean when R² is negative, and what would you check?

Negative R² means the model's squared errors are larger than they would be if you'd just predicted the mean of the target for every example — the model is actively worse than the simplest possible baseline. That's a strong signal something is broken, not just underperforming. I'd check, in order: whether train and test come from the same distribution (compare y_train.mean() and y_test.mean() — a big gap points to a bad split); whether the target was transformed during training (e.g. predicting log(y)) but evaluated without un-transforming the predictions back to y's scale; and whether the features used at inference time actually match what the model was trained on. Negative R² is rarely a subtle modelling issue — it's almost always a pipeline bug.

Q4 — Why might MAPE be a poor choice for a demand-forecasting problem with many low-volume SKUs?

MAPE divides each error by the actual value, so for SKUs with true demand near zero — a product that sells 1 or 2 units a day — a small absolute error like being off by 3 units produces a triple-digit or even undefined percentage error. Those low-volume SKUs then dominate the averaged MAPE even though their absolute business impact is tiny, while high-volume SKUs where the forecast actually matters most get comparatively little weight in the metric. A better choice here is often a weighted MAE (weighted by revenue or volume) or WAPE (weighted absolute percentage error, which divides the sum of absolute errors by the sum of actuals rather than averaging per-item ratios), because both avoid the near-zero-denominator blowup that plain MAPE is vulnerable to.

Q5 — Your model's aggregate MAE looks great, but the business says predictions are bad for one customer segment. How do you investigate?

I'd start with residual analysis rather than trusting the aggregate number. First check whether the mean residual is near zero overall — if it's shifted, the model has a global bias, not just a segment-specific one. Then break MAE down by the segment in question (and a few related cuts — by prediction range, by a key input feature) to see if that segment's error is meaningfully higher than the rest, and check the sign of its mean residual to see whether the model over- or under-predicts for that group specifically. An aggregate metric is a weighted average across every prediction, so a model can look excellent overall while being consistently wrong for a segment that's simply outnumbered by the rest of the data — the fix is always to disaggregate before concluding the model is fine.

What comes next

The Evaluation section is complete. Section 7 — Deep Learning — begins next.

You have now completed every module in the Model Evaluation section: classification metrics, calibration, ROC curves, cross-validation, hyperparameter tuning, model interpretability, and regression metrics. You can honestly evaluate any model — classifier or regressor — and communicate its performance to any audience.

Section 7 — Deep Learning — begins with Module 41. Everything changes: instead of hand-crafted features, the model learns its own representations from raw data. Module 41 builds a neural network from scratch in NumPy — forward pass, backpropagation, gradient descent — before introducing PyTorch.

Next — Module 41 · Deep Learning
Neural Networks from Scratch

Forward pass, backpropagation, and gradient descent built in NumPy before touching PyTorch. The foundation every deep learning framework is built on.

Start →

🎯 Key Takeaways

  • MAE treats all errors proportionally — a 10-minute error is exactly 2× worse than a 5-minute error. RMSE squares the errors first — a 10-minute error is 4× worse than a 5-minute error. Choose based on whether large errors in your domain are disproportionately costly.
  • MAPE expresses error as a percentage of the actual value — scale-independent and useful when targets span different magnitudes. Never use MAPE when true values can be zero — division by zero makes it undefined.
  • R² measures how much better the model is than predicting the mean. R²=0.87 means 87% of variance explained. R²=0 means no better than the mean. Negative R² means worse than the mean — a signal of a severely broken pipeline.
  • Always compare your model against a naive baseline before reporting any metric. If the baseline (always predict mean) has MAE=12.4 and your model has MAE=11.9, the improvement is marginal despite the metric looking reasonable in isolation.
  • The RMSE/MAE ratio reveals the outlier situation. Ratio near 1.0 means errors are uniform. Ratio above 2.0 means a few very large errors are dominating RMSE. Always inspect the error distribution — report percentile errors (50th, 90th, 95th) alongside summary metrics.
  • Residual analysis exposes systematic bias that aggregate metrics hide. Always check: is the mean residual near zero (no bias)? Does error vary by prediction range or input feature? Are the largest errors concentrated in a specific segment? A model with good overall MAE can be systematically wrong for a specific customer group.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...