_quantile.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. # Authors: David Dale <dale.david@mail.ru>
  2. # Christian Lorentzen <lorentzen.ch@gmail.com>
  3. # License: BSD 3 clause
  4. import warnings
  5. from numbers import Real
  6. import numpy as np
  7. from scipy import sparse
  8. from scipy.optimize import linprog
  9. from ..base import BaseEstimator, RegressorMixin, _fit_context
  10. from ..exceptions import ConvergenceWarning
  11. from ..utils import _safe_indexing
  12. from ..utils._param_validation import Hidden, Interval, StrOptions
  13. from ..utils.fixes import parse_version, sp_version
  14. from ..utils.validation import _check_sample_weight
  15. from ._base import LinearModel
  16. class QuantileRegressor(LinearModel, RegressorMixin, BaseEstimator):
  17. """Linear regression model that predicts conditional quantiles.
  18. The linear :class:`QuantileRegressor` optimizes the pinball loss for a
  19. desired `quantile` and is robust to outliers.
  20. This model uses an L1 regularization like
  21. :class:`~sklearn.linear_model.Lasso`.
  22. Read more in the :ref:`User Guide <quantile_regression>`.
  23. .. versionadded:: 1.0
  24. Parameters
  25. ----------
  26. quantile : float, default=0.5
  27. The quantile that the model tries to predict. It must be strictly
  28. between 0 and 1. If 0.5 (default), the model predicts the 50%
  29. quantile, i.e. the median.
  30. alpha : float, default=1.0
  31. Regularization constant that multiplies the L1 penalty term.
  32. fit_intercept : bool, default=True
  33. Whether or not to fit the intercept.
  34. solver : {'highs-ds', 'highs-ipm', 'highs', 'interior-point', \
  35. 'revised simplex'}, default='interior-point'
  36. Method used by :func:`scipy.optimize.linprog` to solve the linear
  37. programming formulation.
  38. From `scipy>=1.6.0`, it is recommended to use the highs methods because
  39. they are the fastest ones. Solvers "highs-ds", "highs-ipm" and "highs"
  40. support sparse input data and, in fact, always convert to sparse csc.
  41. From `scipy>=1.11.0`, "interior-point" is not available anymore.
  42. .. versionchanged:: 1.4
  43. The default of `solver` will change to `"highs"` in version 1.4.
  44. solver_options : dict, default=None
  45. Additional parameters passed to :func:`scipy.optimize.linprog` as
  46. options. If `None` and if `solver='interior-point'`, then
  47. `{"lstsq": True}` is passed to :func:`scipy.optimize.linprog` for the
  48. sake of stability.
  49. Attributes
  50. ----------
  51. coef_ : array of shape (n_features,)
  52. Estimated coefficients for the features.
  53. intercept_ : float
  54. The intercept of the model, aka bias term.
  55. n_features_in_ : int
  56. Number of features seen during :term:`fit`.
  57. .. versionadded:: 0.24
  58. feature_names_in_ : ndarray of shape (`n_features_in_`,)
  59. Names of features seen during :term:`fit`. Defined only when `X`
  60. has feature names that are all strings.
  61. .. versionadded:: 1.0
  62. n_iter_ : int
  63. The actual number of iterations performed by the solver.
  64. See Also
  65. --------
  66. Lasso : The Lasso is a linear model that estimates sparse coefficients
  67. with l1 regularization.
  68. HuberRegressor : Linear regression model that is robust to outliers.
  69. Examples
  70. --------
  71. >>> from sklearn.linear_model import QuantileRegressor
  72. >>> import numpy as np
  73. >>> n_samples, n_features = 10, 2
  74. >>> rng = np.random.RandomState(0)
  75. >>> y = rng.randn(n_samples)
  76. >>> X = rng.randn(n_samples, n_features)
  77. >>> # the two following lines are optional in practice
  78. >>> from sklearn.utils.fixes import sp_version, parse_version
  79. >>> solver = "highs" if sp_version >= parse_version("1.6.0") else "interior-point"
  80. >>> reg = QuantileRegressor(quantile=0.8, solver=solver).fit(X, y)
  81. >>> np.mean(y <= reg.predict(X))
  82. 0.8
  83. """
  84. _parameter_constraints: dict = {
  85. "quantile": [Interval(Real, 0, 1, closed="neither")],
  86. "alpha": [Interval(Real, 0, None, closed="left")],
  87. "fit_intercept": ["boolean"],
  88. "solver": [
  89. StrOptions(
  90. {
  91. "highs-ds",
  92. "highs-ipm",
  93. "highs",
  94. "interior-point",
  95. "revised simplex",
  96. }
  97. ),
  98. Hidden(StrOptions({"warn"})),
  99. ],
  100. "solver_options": [dict, None],
  101. }
  102. def __init__(
  103. self,
  104. *,
  105. quantile=0.5,
  106. alpha=1.0,
  107. fit_intercept=True,
  108. solver="warn",
  109. solver_options=None,
  110. ):
  111. self.quantile = quantile
  112. self.alpha = alpha
  113. self.fit_intercept = fit_intercept
  114. self.solver = solver
  115. self.solver_options = solver_options
  116. @_fit_context(prefer_skip_nested_validation=True)
  117. def fit(self, X, y, sample_weight=None):
  118. """Fit the model according to the given training data.
  119. Parameters
  120. ----------
  121. X : {array-like, sparse matrix} of shape (n_samples, n_features)
  122. Training data.
  123. y : array-like of shape (n_samples,)
  124. Target values.
  125. sample_weight : array-like of shape (n_samples,), default=None
  126. Sample weights.
  127. Returns
  128. -------
  129. self : object
  130. Returns self.
  131. """
  132. X, y = self._validate_data(
  133. X,
  134. y,
  135. accept_sparse=["csc", "csr", "coo"],
  136. y_numeric=True,
  137. multi_output=False,
  138. )
  139. sample_weight = _check_sample_weight(sample_weight, X)
  140. n_features = X.shape[1]
  141. n_params = n_features
  142. if self.fit_intercept:
  143. n_params += 1
  144. # Note that centering y and X with _preprocess_data does not work
  145. # for quantile regression.
  146. # The objective is defined as 1/n * sum(pinball loss) + alpha * L1.
  147. # So we rescale the penalty term, which is equivalent.
  148. alpha = np.sum(sample_weight) * self.alpha
  149. if self.solver == "warn":
  150. warnings.warn(
  151. (
  152. "The default solver will change from 'interior-point' to 'highs' in"
  153. " version 1.4. Set `solver='highs'` or to the desired solver to"
  154. " silence this warning."
  155. ),
  156. FutureWarning,
  157. )
  158. solver = "interior-point"
  159. elif self.solver in (
  160. "highs-ds",
  161. "highs-ipm",
  162. "highs",
  163. ) and sp_version < parse_version("1.6.0"):
  164. raise ValueError(
  165. f"Solver {self.solver} is only available "
  166. f"with scipy>=1.6.0, got {sp_version}"
  167. )
  168. else:
  169. solver = self.solver
  170. if solver == "interior-point" and sp_version >= parse_version("1.11.0"):
  171. raise ValueError(
  172. f"Solver {solver} is not anymore available in SciPy >= 1.11.0."
  173. )
  174. if sparse.issparse(X) and solver not in ["highs", "highs-ds", "highs-ipm"]:
  175. raise ValueError(
  176. f"Solver {self.solver} does not support sparse X. "
  177. "Use solver 'highs' for example."
  178. )
  179. # make default solver more stable
  180. if self.solver_options is None and solver == "interior-point":
  181. solver_options = {"lstsq": True}
  182. else:
  183. solver_options = self.solver_options
  184. # After rescaling alpha, the minimization problem is
  185. # min sum(pinball loss) + alpha * L1
  186. # Use linear programming formulation of quantile regression
  187. # min_x c x
  188. # A_eq x = b_eq
  189. # 0 <= x
  190. # x = (s0, s, t0, t, u, v) = slack variables >= 0
  191. # intercept = s0 - t0
  192. # coef = s - t
  193. # c = (0, alpha * 1_p, 0, alpha * 1_p, quantile * 1_n, (1-quantile) * 1_n)
  194. # residual = y - X@coef - intercept = u - v
  195. # A_eq = (1_n, X, -1_n, -X, diag(1_n), -diag(1_n))
  196. # b_eq = y
  197. # p = n_features
  198. # n = n_samples
  199. # 1_n = vector of length n with entries equal one
  200. # see https://stats.stackexchange.com/questions/384909/
  201. #
  202. # Filtering out zero sample weights from the beginning makes life
  203. # easier for the linprog solver.
  204. indices = np.nonzero(sample_weight)[0]
  205. n_indices = len(indices) # use n_mask instead of n_samples
  206. if n_indices < len(sample_weight):
  207. sample_weight = sample_weight[indices]
  208. X = _safe_indexing(X, indices)
  209. y = _safe_indexing(y, indices)
  210. c = np.concatenate(
  211. [
  212. np.full(2 * n_params, fill_value=alpha),
  213. sample_weight * self.quantile,
  214. sample_weight * (1 - self.quantile),
  215. ]
  216. )
  217. if self.fit_intercept:
  218. # do not penalize the intercept
  219. c[0] = 0
  220. c[n_params] = 0
  221. if solver in ["highs", "highs-ds", "highs-ipm"]:
  222. # Note that highs methods always use a sparse CSC memory layout internally,
  223. # even for optimization problems parametrized using dense numpy arrays.
  224. # Therefore, we work with CSC matrices as early as possible to limit
  225. # unnecessary repeated memory copies.
  226. eye = sparse.eye(n_indices, dtype=X.dtype, format="csc")
  227. if self.fit_intercept:
  228. ones = sparse.csc_matrix(np.ones(shape=(n_indices, 1), dtype=X.dtype))
  229. A_eq = sparse.hstack([ones, X, -ones, -X, eye, -eye], format="csc")
  230. else:
  231. A_eq = sparse.hstack([X, -X, eye, -eye], format="csc")
  232. else:
  233. eye = np.eye(n_indices)
  234. if self.fit_intercept:
  235. ones = np.ones((n_indices, 1))
  236. A_eq = np.concatenate([ones, X, -ones, -X, eye, -eye], axis=1)
  237. else:
  238. A_eq = np.concatenate([X, -X, eye, -eye], axis=1)
  239. b_eq = y
  240. result = linprog(
  241. c=c,
  242. A_eq=A_eq,
  243. b_eq=b_eq,
  244. method=solver,
  245. options=solver_options,
  246. )
  247. solution = result.x
  248. if not result.success:
  249. failure = {
  250. 1: "Iteration limit reached.",
  251. 2: "Problem appears to be infeasible.",
  252. 3: "Problem appears to be unbounded.",
  253. 4: "Numerical difficulties encountered.",
  254. }
  255. warnings.warn(
  256. "Linear programming for QuantileRegressor did not succeed.\n"
  257. f"Status is {result.status}: "
  258. + failure.setdefault(result.status, "unknown reason")
  259. + "\n"
  260. + "Result message of linprog:\n"
  261. + result.message,
  262. ConvergenceWarning,
  263. )
  264. # positive slack - negative slack
  265. # solution is an array with (params_pos, params_neg, u, v)
  266. params = solution[:n_params] - solution[n_params : 2 * n_params]
  267. self.n_iter_ = result.nit
  268. if self.fit_intercept:
  269. self.coef_ = params[1:]
  270. self.intercept_ = params[0]
  271. else:
  272. self.coef_ = params
  273. self.intercept_ = 0.0
  274. return self