This notebook covers robust regression: fitting a line through data points such that the sum of the absolute prediction errors is as small as possible, which makes the line far less sensitive to outliers than ordinary least squares. The absolute value is not linear, so the model needs a trick that splits each error into a positive and a negative part; the same trick linearizes absolute values in other models. Like the applications of Lecture 9 and Multi-Period Inventory Planning, the problem is introduced in the same steps: a practical motivation, the generic model built up with the four modeling steps, the model for a concrete example, its solution in pulp, and possible extensions.
Learning outcomes
On completion of this notebook, you will be able to:
formulate a robust regression problem as an LO model;
linearize absolute values in a model with the split;
adapt the model to quantile regression.
Robust Regression¶
Practical Motivation¶
A factory wants to predict how long a production order will take from its size, to promise delivery dates. It fits a line through the data of past orders. A few of those orders took far longer than usual, for example because a machine broke down. Ordinary least-squares (OLS) regression minimizes the sum of squared prediction errors, so such outliers have a large influence and pull the line toward them, just as a single extreme value pulls the mean. Minimizing the sum of absolute errors instead gives a line that is far less sensitive to outliers, the line analogue of the median. This is called robust regression (or least absolute deviation regression).
Modeling¶
Problem Definition and Example¶
Given data points , , find the line that minimizes the sum of the absolute prediction errors .
As an example, take the five data points
| 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|
| 2 | 4 | 6 | 8 | 10 | |
| 5 | 4 | 9 | 3 | 7 |
which we store as numpy arrays and plot in Figure 1.
import numpy as np
import plotly.graph_objects as go
import pulp
xs = np.array([2, 4, 6, 8, 10])
ys = np.array([5, 4, 9, 3, 7])
points = range(len(xs))Source
TEXT_COLOR = "#111827"
PLOT_LAYOUT = {
"paper_bgcolor": "rgba(0,0,0,0)",
"plot_bgcolor": "rgba(0,0,0,0)",
"font": {"color": TEXT_COLOR, "size": 13},
"margin": {"l": 50, "r": 20, "t": 20, "b": 50},
"height": 380,
"legend": {
"bgcolor": "rgba(0,0,0,0)",
"orientation": "h",
"yanchor": "top",
"y": -0.2,
},
}
AXIS_STYLE = {
"gridcolor": "rgba(128,128,128,0.25)",
"zerolinecolor": "rgba(128,128,128,0.5)",
}
# static pictures: no hover, zoom or drag
PLOT_CONFIG = {"displayModeBar": False, "staticPlot": True}
X_RANGE = [-0.5, 11]
SUBSCRIPT = "₀₁₂₃₄₅₆₇₈₉"
def data_figure() -> go.Figure:
"""The example data points, with the axes used in every plot."""
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=xs,
y=ys,
mode="markers",
marker={"size": 10, "color": "#1f77b4"},
name="data points",
)
)
fig.update_layout(**PLOT_LAYOUT)
fig.update_xaxes(title="x", range=X_RANGE, dtick=2, **AXIS_STYLE)
fig.update_yaxes(title="y", range=[0, 12], dtick=2, **AXIS_STYLE)
return fig
def add_line(
fig: go.Figure, a: float, b: float, name: str, color: str, dash: str
) -> None:
"""Add the line a + bx over the plotted x range."""
line_x = np.array(X_RANGE)
fig.add_trace(
go.Scatter(
x=line_x,
y=a + b * line_x,
mode="lines",
line={"color": color, "dash": dash, "width": 2},
name=name,
)
)Figure 1:The five data points of the example.
Decision Variables¶
We choose the line, so its intercept and slope are decision variables. Both may be negative, so unlike in most models so far they have no sign constraint. It helps to also introduce the prediction error of each data point as a variable,
Objective¶
We want to minimize . The absolute value is not linear, so this is not yet an LO model. There is a standard trick to fix this. Write each error as the difference of two non-negative variables,
and replace by in the objective, which is linear:
Why is this allowed? The same can be written as in many ways, for example . But if both and were positive, we could lower both by the same amount: stays the same and the objective goes down. So at the optimum, at least one of the two is 0, and then . If , the data point lies above the line; if , it lies below the line.
Figure 2 shows this for the example and the line with and , which is not the optimal one. The line crosses the -axis at height and rises for every unit that increases. Data points 1 and 3 lie above the line, with and ; data points 2, 4 and 5 lie below it, with , and . All other and are 0, and the objective value of this line is .
Figure 2:The data points of the example and the line (not optimal). A data point above the line has a positive error , a data point below it a positive . The line starts at height on the -axis and rises by when increases by 2.
Constraints¶
The only constraints connect the errors to the line:
together with . The variables and are free.
Complete LO Model¶
Solving the Example in pulp¶
A variable without lowBound is free in pulp (its bounds default to and
), which is what we need for the intercept and the slope. The data points are
the numpy arrays xs and ys from above, which pulp’s expressions accept like
ordinary numbers.
robust_regression = pulp.LpProblem(
name="robust_regression", sense=pulp.LpMinimize
)
intercept = pulp.LpVariable(name="a")
slope = pulp.LpVariable(name="b")
e_pos = [pulp.LpVariable(name=f"ep_{k + 1}", lowBound=0) for k in points]
e_neg = [pulp.LpVariable(name=f"en_{k + 1}", lowBound=0) for k in points]
robust_regression += pulp.lpSum(e_pos[k] + e_neg[k] for k in points)
for k in points:
prediction = intercept + slope * xs[k]
robust_regression += ys[k] - prediction == e_pos[k] - e_neg[k]
robust_regression.solve(pulp.PULP_CBC_CMD(msg=False))
a_hat, b_hat = intercept.value(), slope.value()
print("status:", pulp.LpStatus[robust_regression.status])
print(f"line: y = {a_hat:.2f} + {b_hat:.2f} x")
print("sum of absolute errors:", robust_regression.objective.value())status: Optimal
line: y = 4.50 + 0.25 x
sum of absolute errors: 8.0
Figure 3:The five data points of the example and the line that minimizes the sum of absolute errors.
The optimal line passes exactly through the first and the last data point. That is no coincidence: as in every LO problem, there is an optimal solution in a corner point, and for this model that means a line through (at least) two of the data points. Its objective value is 8, lower than the 10 of the line in Figure 2.
Extensions¶
Quantile Regression¶
Weighting the two parts of the error differently tilts the line toward the upper or lower points. For a quantile level , the model becomes
This is quantile regression. With , both parts get the same weight, which gives the same optimal lines as robust regression (the objective is just halved). With , a point above the line costs 9 times as much as a point below it, so the line moves up until about 90% of the data points lie below it. This is useful for promising delivery times that are met in 90% of the cases. Only the objective changes in the pulp model:
quantile_line = {}
for p in [0.5, 0.9]:
quantile_regression = pulp.LpProblem(
name="quantile_regression", sense=pulp.LpMinimize
)
intercept = pulp.LpVariable(name="a")
slope = pulp.LpVariable(name="b")
e_pos = [pulp.LpVariable(name=f"ep_{k + 1}", lowBound=0) for k in points]
e_neg = [pulp.LpVariable(name=f"en_{k + 1}", lowBound=0) for k in points]
above = pulp.lpSum(e_pos)
below = pulp.lpSum(e_neg)
quantile_regression += p * above + (1 - p) * below
for k in points:
prediction = intercept + slope * xs[k]
quantile_regression += ys[k] - prediction == e_pos[k] - e_neg[k]
quantile_regression.solve(pulp.PULP_CBC_CMD(msg=False))
quantile_line[p] = (intercept.value(), slope.value())
print(f"p = {p}: y = {intercept.value():.2f} + {slope.value():.2f} x")p = 0.5: y = 4.50 + 0.25 x
p = 0.9: y = 3.00 + 1.00 x
Figure 4:Quantile regression on the example data for (the robust regression line) and .
With only five data points, the line passes through data points 1 and 3, and the other three lie below it. With more data, roughly 10% of the points would lie above the line.
Absolute Values in Other Models¶
The same split linearizes any that appears in an objective that is minimized with a non-negative coefficient (or maximized with a non-positive one). The trick also works when the absolute value is a penalty rather than the whole objective. For the product-mix problem, suppose we dislike making the two products in very different quantities and add to the profit. Introduce with and subtract from the objective.
References¶
Koole, G. (2019). An Introduction to Business Analytics. §6.7 “Modeling Tricks” (quantile regression). Spreadsheet material replaced with pulp.
PuLP documentation: https://
coin -or .github .io /pulp/