Skip to content

Commit 0669811

Browse files
committed
fix huber loss error and refactor objective
1 parent 2accd63 commit 0669811

3 files changed

Lines changed: 124 additions & 179 deletions

File tree

mqboost/objective.py

Lines changed: 110 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import warnings
2-
from typing import Any, Callable
32

43
import lightgbm as lgb
54
import numpy as np
@@ -9,131 +8,83 @@
98
from mqboost.base import ModelName, ObjectiveName, ValidationException
109

1110

12-
def calc_rho(error: npt.NDArray, alpha: float) -> npt.NDArray:
13-
"""Compute rho for the given error and alpha."""
11+
def calc_rho(error: npt.NDArray, alpha: npt.NDArray | float) -> npt.NDArray:
12+
"""Compute rho (pinball loss) for the given error and alpha."""
13+
# L = (alpha - I(error < 0)) * error
1414
return (alpha - (error < 0).astype(int)) * error
1515

1616

1717
def calc_check_grad_hess(
18-
error: npt.NDArray, alpha: float
18+
error: npt.NDArray, alpha: npt.NDArray | float
1919
) -> tuple[npt.NDArray, npt.NDArray]:
2020
"""Compute gradient and Hessian for the check loss."""
21+
# dL/dp = I(error < 0) - alpha
22+
# d2L/dp2 = 1 as a proxy for Hessian
2123
return (error < 0).astype(int) - alpha, np.ones_like(error)
2224

2325

2426
def calc_huber_grad_hess(
25-
error: npt.NDArray, alpha: float, delta: float
27+
error: npt.NDArray, alpha: npt.NDArray | float, delta: float
2628
) -> tuple[npt.NDArray, npt.NDArray]:
27-
"""Compute gradient and Hessian for the Huber loss."""
29+
"""Compute gradient and Hessian for the Huber loss (Smooth Quantile Loss)."""
2830
abs_error = np.abs(error)
29-
smaller_delta = (abs_error <= delta).astype(int)
30-
bigger_delta = (abs_error > delta).astype(int)
31-
rho_val = calc_rho(error=error, alpha=alpha)
31+
mask = (abs_error <= delta).astype(float)
32+
33+
# Gradient for linear part
3234
check_grad, check_hess = calc_check_grad_hess(error=error, alpha=alpha)
33-
return rho_val * smaller_delta + check_grad * bigger_delta, check_hess
35+
# Gradient for Huber part
36+
# dL/dp = check_grad * (abs_error / delta)
37+
huber_grad = check_grad * (abs_error / delta)
38+
grad = mask * huber_grad + (1 - mask) * check_grad
39+
40+
# Hessian for Huber part
41+
# d2L/dp2 = |check_grad| / delta
42+
huber_hess = np.abs(check_grad) / delta
43+
# For linear part, we use check_hess as a proxy for Hessian
44+
hess = mask * huber_hess + (1 - mask) * check_hess
45+
46+
return grad, hess
3447

3548

3649
def calc_approx_grad_hess(
37-
error: npt.NDArray, alpha: float, epsilon: float
50+
error: npt.NDArray, alpha: npt.NDArray | float, epsilon: float
3851
) -> tuple[npt.NDArray, npt.NDArray]:
3952
"""Compute gradient and Hessian for the approximate loss (MM loss)."""
53+
# dL/dp = 0.5 * (1 - 2 * alpha - error / (epsilon + |error|))
4054
approx_grad = 0.5 * (1 - 2 * alpha - error / (epsilon + np.abs(error)))
55+
56+
# d2L/dp2 = 1 / (2 * (epsilon + |error|))
4157
approx_hess = 1 / (2 * (epsilon + np.abs(error)))
4258
return approx_grad, approx_hess
4359

4460

45-
def train_pred_reshape(
46-
dtrain: lgb.Dataset | xgb.DMatrix,
47-
y_pred: npt.NDArray,
48-
len_alpha: int,
49-
) -> tuple[npt.NDArray, npt.NDArray]:
50-
"""Reshape training predictions and labels to match the number of quantile levels."""
51-
y_train = dtrain.get_label()
52-
if not isinstance(y_train, np.ndarray):
53-
y_train = np.array(y_train)
54-
return y_train.reshape(len_alpha, -1), y_pred.reshape(len_alpha, -1)
55-
56-
57-
def compute_grad_hess_single_alpha(
58-
y_true: npt.NDArray,
59-
y_pred: npt.NDArray,
60-
alpha: float,
61-
calc_grad_hess_fn: Callable,
62-
n: int,
63-
**kwargs,
64-
) -> tuple[npt.NDArray, npt.NDArray]:
65-
"""Compute gradient and Hessian using the given function for a single alpha value."""
66-
error = y_true - y_pred
67-
grad, hess = calc_grad_hess_fn(error=error, alpha=alpha, **kwargs)
68-
return grad / n, hess / n
69-
70-
71-
def compute_grad_hess(
72-
calc_grad_hess_fn: Callable,
73-
) -> Callable[...,]:
74-
"""Return a function that computes gradient and Hessian for a given calc_grad_hess_fn."""
75-
76-
def _compute_grads_hess(
77-
y_pred: npt.NDArray,
78-
dtrain: lgb.Dataset | xgb.DMatrix,
79-
alphas: list[float],
80-
weight: npt.NDArray | None,
81-
**kwargs: Any,
82-
) -> tuple[npt.NDArray, npt.NDArray]:
83-
len_alpha = len(alphas)
84-
y_train_reshaped, y_pred_reshaped = train_pred_reshape(
85-
y_pred=y_pred, dtrain=dtrain, len_alpha=len_alpha
86-
)
87-
88-
grads: list[np.ndarray] = []
89-
hess: list[np.ndarray] = []
90-
len_y = len(y_train_reshaped[0])
91-
for alpha_inx in range(len(alphas)):
92-
_grad, _hess = compute_grad_hess_single_alpha(
93-
y_train_reshaped[alpha_inx],
94-
y_pred_reshaped[alpha_inx],
95-
alphas[alpha_inx],
96-
calc_grad_hess_fn,
97-
len_y,
98-
**kwargs,
99-
)
100-
grads.append(_grad)
101-
hess.append(_hess)
102-
103-
if isinstance(weight, np.ndarray):
104-
return np.concatenate(grads) * weight, np.concatenate(hess) * weight
105-
else:
106-
return np.concatenate(grads), np.concatenate(hess)
107-
108-
return _compute_grads_hess
109-
110-
111-
# Gradient and Hessian functions
112-
check_loss_grad_hess = compute_grad_hess(calc_grad_hess_fn=calc_check_grad_hess)
113-
huber_loss_grad_hess = compute_grad_hess(calc_grad_hess_fn=calc_huber_grad_hess)
114-
approx_loss_grad_hess = compute_grad_hess(calc_grad_hess_fn=calc_approx_grad_hess)
61+
def _get_alpha_expanded(alphas: list[float], total_len: int) -> tuple[npt.NDArray, int]:
62+
"""Helper to expand alphas and get original dataset size."""
63+
n = total_len // len(alphas)
64+
return np.repeat(alphas, n), n
11565

11666

11767
def eval_check_loss(
118-
y_pred: np.ndarray,
68+
y_pred: npt.NDArray,
11969
dtrain: lgb.Dataset | xgb.DMatrix,
12070
alphas: list[float],
12171
) -> float:
122-
"""Evaluate the check loss function."""
123-
len_alpha = len(alphas)
124-
y_train_reshaped, y_pred_reshaped = train_pred_reshape(
125-
y_pred=y_pred, dtrain=dtrain, len_alpha=len_alpha
126-
)
127-
loss: float = 0.0
128-
for alpha_inx in range(len_alpha):
129-
_err_for_alpha = y_train_reshaped[alpha_inx] - y_pred_reshaped[alpha_inx]
130-
_loss = calc_rho(error=_err_for_alpha, alpha=alphas[alpha_inx])
131-
loss += float(np.mean(_loss))
132-
return loss
72+
"""Evaluate the check loss function using vectorized operations."""
73+
y_true = dtrain.get_label()
74+
if not isinstance(y_true, np.ndarray):
75+
y_true = np.array(y_true)
76+
77+
alphas_expanded, n = _get_alpha_expanded(alphas, len(y_true))
78+
error = y_true - y_pred
79+
loss_all = calc_rho(error=error, alpha=alphas_expanded)
80+
81+
# Return the sum of mean losses across all quantiles
82+
loss_reshaped = loss_all.reshape(len(alphas), n)
83+
return float(np.sum(np.mean(loss_reshaped, axis=1)))
13384

13485

13586
def validate_epsilon(epsilon: float) -> None:
136-
"""Validate epsilon parameter ensuring it is positive float"""
87+
"""Validate epsilon parameter ensuring it is a positive float."""
13788
if not isinstance(epsilon, float):
13889
raise ValidationException("Epsilon is not float type")
13990

@@ -142,7 +93,7 @@ def validate_epsilon(epsilon: float) -> None:
14293

14394

14495
def validate_delta(delta: float) -> None:
145-
"""Validates the delta parameter ensuring it is a positive float and less than or equal to 0.05."""
96+
"""Validate the delta parameter ensuring it is a positive float and less than or equal to 0.05."""
14697
_delta_upper_bound: float = 0.05
14798

14899
if not isinstance(delta, float):
@@ -155,77 +106,77 @@ def validate_delta(delta: float) -> None:
155106
warnings.warn("Delta should be 0.05 or less.")
156107

157108

158-
def build_fobj(
159-
alphas: list[float],
160-
objective: ObjectiveName,
161-
delta: float,
162-
epsilon: float,
163-
weight: np.ndarray | None,
164-
) -> Callable[..., tuple[npt.NDArray, npt.NDArray]]:
165-
"""Return fobj function."""
166-
if objective == ObjectiveName.approx:
167-
validate_epsilon(epsilon)
168-
169-
if objective == ObjectiveName.huber:
170-
validate_delta(delta)
171-
172-
def fobj(
173-
y_pred: npt.NDArray, dtrain: lgb.Dataset | xgb.DMatrix
174-
) -> tuple[npt.NDArray, npt.NDArray]:
175-
if objective == ObjectiveName.check:
176-
return check_loss_grad_hess(
177-
y_pred=y_pred,
178-
dtrain=dtrain,
179-
alphas=alphas,
180-
weight=weight,
181-
)
182-
183-
elif objective == ObjectiveName.huber:
184-
return huber_loss_grad_hess(
185-
y_pred=y_pred,
186-
dtrain=dtrain,
187-
alphas=alphas,
188-
weight=weight,
189-
delta=delta,
190-
)
191-
192-
elif objective == ObjectiveName.approx:
193-
return approx_loss_grad_hess(
194-
y_pred=y_pred,
195-
dtrain=dtrain,
196-
alphas=alphas,
197-
weight=weight,
198-
epsilon=epsilon,
199-
)
200-
201-
return fobj
202-
203-
204-
def build_feval(
205-
model: ModelName, alphas: list[float]
206-
) -> Callable[[npt.NDArray, lgb.Dataset | xgb.DMatrix], tuple]:
207-
"""Return feval function."""
208-
209-
def feval(y_pred: npt.NDArray, dtrain: lgb.Dataset | xgb.DMatrix) -> tuple:
210-
loss = eval_check_loss(y_pred, dtrain, alphas)
211-
if model == ModelName.lightgbm:
212-
return "check_loss", loss, False
213-
elif model == ModelName.xgboost:
214-
return "check_loss", loss
215-
216-
return feval
217-
218-
219109
class MQObjective:
110+
"""MQObjective encapsulates the objective and evaluation functions for the MQRegressor."""
111+
220112
def __init__(
221113
self,
222114
alphas: list[float],
223115
objective: ObjectiveName,
224116
model: ModelName,
225117
delta: float,
226118
epsilon: float,
227-
weight: np.ndarray | None,
119+
weight: npt.NDArray | None = None,
228120
) -> None:
229121
"""Initialize the MQObjective."""
230-
self.fobj = build_fobj(alphas, objective, delta, epsilon, weight)
231-
self.feval = build_feval(model, alphas)
122+
self.alphas = alphas
123+
self.objective = objective
124+
self.model = model
125+
self.delta = delta
126+
self.epsilon = epsilon
127+
self.weight = weight
128+
129+
# Pre-validate parameters
130+
if self.objective == ObjectiveName.approx:
131+
validate_epsilon(self.epsilon)
132+
if self.objective == ObjectiveName.huber:
133+
validate_delta(self.delta)
134+
135+
def fobj(
136+
self, y_pred: npt.NDArray, dtrain: lgb.Dataset | xgb.DMatrix
137+
) -> tuple[npt.NDArray, npt.NDArray]:
138+
"""Custom objective function for LightGBM and XGBoost."""
139+
y_true = dtrain.get_label()
140+
if not isinstance(y_true, np.ndarray):
141+
y_true = np.array(y_true)
142+
143+
alphas_expanded, n = _get_alpha_expanded(self.alphas, len(y_true))
144+
error = y_true - y_pred
145+
146+
# Calculate gradients and Hessians based on objective
147+
if self.objective == ObjectiveName.check:
148+
grads, hess = calc_check_grad_hess(error, alphas_expanded)
149+
elif self.objective == ObjectiveName.huber:
150+
grads, hess = calc_huber_grad_hess(error, alphas_expanded, self.delta)
151+
elif self.objective == ObjectiveName.approx:
152+
grads, hess = calc_approx_grad_hess(error, alphas_expanded, self.epsilon)
153+
else:
154+
raise ValueError(f"Unknown objective: {self.objective}")
155+
156+
# Normalize and apply weights
157+
grads /= n
158+
hess /= n
159+
160+
if isinstance(self.weight, np.ndarray):
161+
return grads * self.weight, hess * self.weight
162+
return grads, hess
163+
164+
def feval(
165+
self, y_pred: npt.NDArray, dtrain: lgb.Dataset | xgb.DMatrix
166+
) -> tuple[str, float, bool] | tuple[str, float]:
167+
"""Custom evaluation function for LightGBM and XGBoost."""
168+
if self.model == ModelName.lightgbm:
169+
return self.lgb_feval(y_pred, dtrain) # type: ignore
170+
return self.xgb_feval(y_pred, dtrain) # type: ignore
171+
172+
def lgb_feval(
173+
self, y_pred: npt.NDArray, dtrain: lgb.Dataset
174+
) -> tuple[str, float, bool]:
175+
"""Custom evaluation function for LightGBM."""
176+
loss = eval_check_loss(y_pred, dtrain, self.alphas)
177+
return "check_loss", loss, False
178+
179+
def xgb_feval(self, y_pred: npt.NDArray, dtrain: xgb.DMatrix) -> tuple[str, float]:
180+
"""Custom evaluation function for XGBoost."""
181+
loss = eval_check_loss(y_pred, dtrain, self.alphas)
182+
return "check_loss", loss

mqboost/regressor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def fit(
105105
self.model = lgb.train(
106106
train_set=dataset.dtrain,
107107
params=params,
108-
feval=self.MQObj.feval,
108+
feval=self.MQObj.lgb_feval,
109109
valid_sets=[eval_set_dtrain],
110110
**kwargs,
111111
)
@@ -115,7 +115,7 @@ def fit(
115115
verbose_eval=False,
116116
params=params,
117117
obj=self.MQObj.fobj,
118-
custom_metric=self.MQObj.feval,
118+
custom_metric=self.MQObj.xgb_feval,
119119
evals=[(eval_set_dtrain, "eval")],
120120
**kwargs,
121121
)

0 commit comments

Comments
 (0)