11import warnings
2- from typing import Any , Callable
32
43import lightgbm as lgb
54import numpy as np
98from 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
1717def 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
2426def 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
3649def 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
11767def 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
13586def 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
14495def 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-
219109class 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
0 commit comments