_isotonic.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. from typing import TYPE_CHECKING
  2. import numpy as np
  3. from ._optimize import OptimizeResult
  4. from ._pava_pybind import pava
  5. if TYPE_CHECKING:
  6. import numpy.typing as npt
  7. __all__ = ["isotonic_regression"]
  8. def isotonic_regression(
  9. y: "npt.ArrayLike",
  10. *,
  11. weights: "npt.ArrayLike | None" = None,
  12. increasing: bool = True,
  13. ) -> OptimizeResult:
  14. r"""Nonparametric isotonic regression.
  15. A (not strictly) monotonically increasing array `x` with the same length
  16. as `y` is calculated by the pool adjacent violators algorithm (PAVA), see
  17. [1]_. See the Notes section for more details.
  18. Parameters
  19. ----------
  20. y : (N,) array_like
  21. Response variable.
  22. weights : (N,) array_like or None
  23. Case weights.
  24. increasing : bool
  25. If True, fit monotonic increasing, i.e. isotonic, regression.
  26. If False, fit a monotonic decreasing, i.e. antitonic, regression.
  27. Default is True.
  28. Returns
  29. -------
  30. res : OptimizeResult
  31. The optimization result represented as a ``OptimizeResult`` object.
  32. Important attributes are:
  33. - ``x``: The isotonic regression solution, i.e. an increasing (or
  34. decreasing) array of the same length than y, with elements in the
  35. range from min(y) to max(y).
  36. - ``weights`` : Array with the sum of case weights for each block
  37. (or pool) B.
  38. - ``blocks``: Array of length B+1 with the indices of the start
  39. positions of each block (or pool) B. The j-th block is given by
  40. ``x[blocks[j]:blocks[j+1]]`` for which all values are the same.
  41. Notes
  42. -----
  43. Given data :math:`y` and case weights :math:`w`, the isotonic regression
  44. solves the following optimization problem:
  45. .. math::
  46. \operatorname{argmin}_{x_i} \sum_i w_i (y_i - x_i)^2 \quad
  47. \text{subject to } x_i \leq x_j \text{ whenever } i \leq j \,.
  48. For every input value :math:`y_i`, it generates a value :math:`x_i` such
  49. that :math:`x` is increasing (but not strictly), i.e.
  50. :math:`x_i \leq x_{i+1}`. This is accomplished by the PAVA.
  51. The solution consists of pools or blocks, i.e. neighboring elements of
  52. :math:`x`, e.g. :math:`x_i` and :math:`x_{i+1}`, that all have the same
  53. value.
  54. Most interestingly, the solution stays the same if the squared loss is
  55. replaced by the wide class of Bregman functions which are the unique
  56. class of strictly consistent scoring functions for the mean, see [2]_
  57. and references therein.
  58. The implemented version of PAVA according to [1]_ has a computational
  59. complexity of O(N) with input size N.
  60. References
  61. ----------
  62. .. [1] Busing, F. M. T. A. (2022).
  63. Monotone Regression: A Simple and Fast O(n) PAVA Implementation.
  64. Journal of Statistical Software, Code Snippets, 102(1), 1-25.
  65. :doi:`10.18637/jss.v102.c01`
  66. .. [2] Jordan, A.I., Mühlemann, A. & Ziegel, J.F.
  67. Characterizing the optimal solutions to the isotonic regression
  68. problem for identifiable functionals.
  69. Ann Inst Stat Math 74, 489-514 (2022).
  70. :doi:`10.1007/s10463-021-00808-0`
  71. Examples
  72. --------
  73. This example demonstrates that ``isotonic_regression`` really solves a
  74. constrained optimization problem.
  75. >>> import numpy as np
  76. >>> from scipy.optimize import isotonic_regression, minimize
  77. >>> y = [1.5, 1.0, 4.0, 6.0, 5.7, 5.0, 7.8, 9.0, 7.5, 9.5, 9.0]
  78. >>> def objective(yhat, y):
  79. ... return np.sum((yhat - y)**2)
  80. >>> def constraint(yhat, y):
  81. ... # This is for a monotonically increasing regression.
  82. ... return np.diff(yhat)
  83. >>> result = minimize(objective, x0=y, args=(y,),
  84. ... constraints=[{'type': 'ineq',
  85. ... 'fun': lambda x: constraint(x, y)}])
  86. >>> result.x
  87. array([1.25 , 1.25 , 4. , 5.56666667, 5.56666667,
  88. 5.56666667, 7.8 , 8.25 , 8.25 , 9.25 ,
  89. 9.25 ])
  90. >>> result = isotonic_regression(y)
  91. >>> result.x
  92. array([1.25 , 1.25 , 4. , 5.56666667, 5.56666667,
  93. 5.56666667, 7.8 , 8.25 , 8.25 , 9.25 ,
  94. 9.25 ])
  95. The big advantage of ``isotonic_regression`` compared to calling
  96. ``minimize`` is that it is more user friendly, i.e. one does not need to
  97. define objective and constraint functions, and that it is orders of
  98. magnitudes faster. On commodity hardware (in 2023), for normal distributed
  99. input y of length 1000, the minimizer takes about 4 seconds, while
  100. ``isotonic_regression`` takes about 200 microseconds.
  101. """
  102. yarr = np.atleast_1d(y) # Check yarr.ndim == 1 is implicit (pybind11) in pava.
  103. order = slice(None) if increasing else slice(None, None, -1)
  104. x = np.array(yarr[order], order="C", dtype=np.float64, copy=True)
  105. if weights is None:
  106. wx = np.ones_like(yarr, dtype=np.float64)
  107. else:
  108. warr = np.atleast_1d(weights)
  109. if not (yarr.ndim == warr.ndim == 1 and yarr.shape[0] == warr.shape[0]):
  110. raise ValueError(
  111. "Input arrays y and w must have one dimension of equal length."
  112. )
  113. if np.any(warr <= 0):
  114. raise ValueError("Weights w must be strictly positive.")
  115. wx = np.array(warr[order], order="C", dtype=np.float64, copy=True)
  116. n = x.shape[0]
  117. r = np.full(shape=n + 1, fill_value=-1, dtype=np.intp)
  118. x, wx, r, b = pava(x, wx, r)
  119. # Now that we know the number of blocks b, we only keep the relevant part
  120. # of r and wx.
  121. # As information: Due to the pava implementation, after the last block
  122. # index, there might be smaller numbers appended to r, e.g.
  123. # r = [0, 10, 8, 7] which in the end should be r = [0, 10].
  124. r = r[:b + 1] # type: ignore[assignment]
  125. wx = wx[:b]
  126. if not increasing:
  127. x = x[::-1]
  128. wx = wx[::-1]
  129. r = r[-1] - r[::-1]
  130. return OptimizeResult(
  131. x=x,
  132. weights=wx,
  133. blocks=r,
  134. )