Esempio n. 1
0
def isotonic_regression(sequence: Sequence[float],
                        weights: Optional[Sequence[float]] = None,
                        increasing: bool = True) -> np.ndarray:
    """Returns a monotonic sequence which most closely approximates sequence.

  Args:
    sequence: (numpy array of floats) sequence to monotonize.
    weights: (numpy array of floats) weights to use for the elements in
      sequence.
    increasing: (bool) True if the sequence should be monotonically increasing,
      otherwise decreasing. Defaults to True.

  Returns:
    A monotonic array of floats approximating the given sequence.
  """
    sequence = np.array(sequence, copy=False, dtype=float)
    if len(sequence) <= 1:
        return sequence

    if not increasing:
        return -isotonic_regression(-sequence, weights, True)

    if weights is None:
        weights = np.ones_like(sequence)
    else:
        weights = np.array(weights, copy=False, dtype=float)
        utils.expect(
            len(weights) == len(sequence),
            'Weights must be same size as sequence.')
        utils.expect((weights > 0).all(), 'Weights must be positive.')

    return _pool_adjacent_violators(sequence, weights)
Esempio n. 2
0
def weighted_pearson_correlation(x: np.ndarray, y: np.ndarray,
                                 w: np.ndarray) -> float:
    """Computes weighted correlation between x and y.

  Args:
    x: (numerical numpy array) First feature for the correlation.
    y: (numerical numpy array) Second feature for the correlation.
    w: (numerical numpy array) Weights of the points.

  Raises:
    ValueError: if x, y, w have different lengths or are empty.

  Returns:
    The weighted correlation between x and y, from -1 (perfect inverse
    correlation) to 1 (perfect correlation). 0 indicates no correlation.
    NaN if all x-values or all y-values are the same.
    https://en.wikipedia.org/wiki/Pearson_correlation_coefficient
  """
    utils.expect(
        len(x) == len(y) == len(w) >= 1,
        'x, y, and w must be nonempty and equal in length.')
    xm = x - np.average(x, weights=w)
    ym = y - np.average(y, weights=w)
    if (xm == xm[0]).all() or (ym == ym[0]).all():
        # Correlation isn't defined when one variable is constant.
        return float('nan')

    covxy = np.average(xm * ym, weights=w)
    covxx = np.average(xm**2, weights=w)
    covyy = np.average(ym**2, weights=w)
    return covxy / np.sqrt(covxx * covyy)
Esempio n. 3
0
    def from_string(cls, s: str) -> 'PWLCurve':
        """Parses a PWLCurve from the given string.

    Syntax is that emitted by __str__: PWLCurve({points}, fx="{fx_name}")

    Only fxs present in STR_TO_FX will be parsed.

    Args:
      s: The string to parse.

    Returns:
      The parsed PWLCurve.
    """
        prefix = 'PWLCurve('
        utils.expect(
            s.startswith(prefix) and s.endswith(')'),
            'String must begin with "%s" and end with ")"' % prefix)
        s = s[len(prefix) - 1:]
        idx = s.find('fx=')
        if idx < 0:
            return cls(ast.literal_eval(s))
        fx_str = ast.literal_eval(s[idx + len('fx='):-1])
        fx = cls.STR_TO_FX.get(fx_str)
        utils.expect(fx is not None, 'Invalid fx "%s" specified' % fx_str)
        control_points = ast.literal_eval(s[:s.rfind(',')] + ')')
        return cls(control_points, fx)
Esempio n. 4
0
def sample_condense_points(
        sorted_x: np.ndarray, y: np.ndarray, w: np.ndarray, num_knots: int
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Picks knots and linearly condenses (sorted_x, y, w) around those knots.

  Args:
    sorted_x: (numpy array) independent variable in sorted order.
    y: (numpy array) dependent variable.
    w: (numpy array) the weights on data points.
    num_knots: (int) Number of knot-x candidates to return.

  Returns:
    A tuple of 4 lists: x_knots, condensed_x, condensed_y, condensed_w.
  """
    utils.expect(num_knots >= 2, 'num_knots must be at least 2.')
    utils.expect(len(sorted_x) == len(y) == len(w))

    sorted_x, y, w = utils.fuse_sorted_points(sorted_x, y, w)
    if len(sorted_x) <= num_knots:
        return sorted_x, sorted_x, y, w

    knot_xs = _pick_knot_candidates(sorted_x, w, num_knots)
    condensed_x, condensed_y, condensed_w = (condense_around_knots(
        sorted_x, y, w, knot_xs))
    return knot_xs, condensed_x, condensed_y, condensed_w
Esempio n. 5
0
def linear_condense(
        x: np.ndarray, y: np.ndarray, w: np.ndarray
) -> Tuple[Sequence[float], Sequence[float], Sequence[float]]:
    """Returns X', Y', W' that replicates the linear fit MSE of x, y, w.

  This function compresses an arbitrary number of (x,y,weight) points into at
  most two compressed points, such that the difference of the MSEs of any two
  lines is the same on the compressed points as it is on the given points. The
  two compressed points lie on the best fit line of the original points, and
  have the same centroid and weight sum as the original points. They also meet
  other invariants derived through linear algebra.

  Args:
    x: (numpy array) independent variable.
    y: (numpy array) dependent variable.
    w: (numpy array) the weights on data points. Each weight must be positive.

  Returns:
    A tuple of 3 lists X', Y', W', each of length no greater than two, such that
    the different of the MSEs of any two lines is the same on X', Y', W' as it
    is on x, y, w.

  """
    utils.expect(len(x) == len(y) == len(w))
    x = np.array(x, dtype=float, copy=False)
    y = np.array(y, dtype=float, copy=False)
    w = np.array(w, dtype=float, copy=False)
    if len(x) <= 2:
        return x, y, w

    # Math is simpler and stabler if we recenter x and y to a zero centroid.
    # We'll add centroid_x and centroid_y back at the end.
    x, y, centroid_x, centroid_y = _recenter_at_zero(x, y, w)
    weight_sum = w.sum()
    centroid_as_fallback = ([centroid_x], [centroid_y], [weight_sum])

    xmin, xmax = np.min(x), np.max(x)
    if not xmin < 0 < xmax:
        return centroid_as_fallback

    xxw = np.dot(x * x, w)
    x_variance = xxw / weight_sum  # Weighted variance.

    x1 = -math.sqrt(x_variance * -xmin / xmax)
    x2 = math.sqrt(x_variance * xmax / -xmin)

    w1 = weight_sum * xmax / (xmax - xmin)
    w2 = weight_sum - w1
    if w1 <= 0 or w2 <= 0:  # Only possible if float precision fails.
        return centroid_as_fallback

    # The best fit line passes through the centroid, so the y-intercept is 0.
    bestfit_slope = np.dot(x * y, w) / xxw
    y1 = bestfit_slope * x1
    y2 = bestfit_slope * x2

    # Add back the centroid of the original data.
    return ([x1 + centroid_x,
             x2 + centroid_x], [y1 + centroid_y, y2 + centroid_y], [w1, w2])
Esempio n. 6
0
 def __init__(self, submodels: List[Union[ColumnModel, PWLCurveModel,
                                          EnumCurveModel]], name: str):
   self._name = name
   utils.expect(
       len(set(model.feature_name for model in submodels)) == len(submodels),
       'Duplicate submodels for some features')
   self._submodels = collections.OrderedDict([
       (model.feature_name, model)
       for model in sorted(submodels, key=lambda m: m.feature_name)
   ])
   super(AdditiveModel, self).__init__()
Esempio n. 7
0
    def _get_weighted_matrix(self, knot_xs: Sequence[float]) -> np.ndarray:
        """Computes the matrix 'A' in ||b - Av||^2, weighted by _weight_matrix.

    Args:
      knot_xs: (float list or numpy array) X coordinates of current knot points.
        Must be unique and in ascending order.

    Returns:
      Two-dimensional matrix A such that A_ij represents the weight on the ith
      point in the data, multiplied by the weight that the ith point puts on the
      jth knot's delta y value.
    """
        knot_xs = list(map(float, knot_xs))  # float is faster than np.float64.
        # First we build the matrix for 'A' in A * knot_delta_ys ~= y_values.
        # For numpy vectorization speed, we use one row per knot and one column per
        # x-value during setup, and then take the transpose to get one row per x
        # value and one column per knot. This is faster because it allows us to fill
        # continuous blocks in the matrix's memory.
        width = len(self._x)
        height = len(knot_xs)
        utils.expect(
            height <= width,
            'Solve() is underdetermined with more knots than points.')
        matrix = np.zeros(width * height, dtype=float)
        knot_indices_in_xs = np.searchsorted(self._x, knot_xs)

        # For x_i < knot_xs[0], A_i0 = 1.0 and all other A_ij = 0.0.
        matrix[0:knot_indices_in_xs[0]] = 1.0

        # For x_i >= knot_xs[j], A_ij = 1.0.
        for row, index in enumerate(knot_indices_in_xs):
            matrix[row * width + index:(row + 1) * width] = 1.0

        # For knot_xs[j-1] <= x_i < knot_xs[j],
        # A_ij = (x_i - knot_xs[j-1]) / (knot_xs[j] - knot_xs[j-1]).
        for index in range(1, height):
            lower, upper = knot_indices_in_xs[index -
                                              1], knot_indices_in_xs[index]
            between_xs = self._x[lower:upper]

            lower_knot = knot_xs[index - 1]
            upper_knot = knot_xs[index]

            upper_knot_weight = (between_xs - lower_knot) / (upper_knot -
                                                             lower_knot)
            upper_row = width * index
            matrix[upper_row + lower:upper_row + upper] = upper_knot_weight

        # Apply the weights once the matrix is full.
        matrix = matrix.reshape((height, width)).T
        weighted_matrix = matrix * self._sqrt_w
        return weighted_matrix
Esempio n. 8
0
def fit_pwl_points(
    x_knots: np.ndarray,
    x: np.ndarray,
    y: np.ndarray,
    w: np.ndarray,
    num_segments: int,
    min_slope: Optional[float] = None,
    max_slope: Optional[float] = None,
    bitonic_peak: Optional[float] = None,
    bitonic_concave_down: Optional[bool] = None,
    required_x_knots: Optional[Sequence[float]] = None
) -> Tuple[Sequence[float], Sequence[float]]:
    """Fits a num_segments segment PWL to the sample points.

  Args:
    x_knots: (numpy array of floats) X-axis knot candidates. Must be unique, and
      sorted in ascending order.
    x: (numpy array of floats) X-axis values for minimizing mean squared error.
      Must be unique, and sorted in ascending order.
    y: (numpy array of floats) Y-axis values corresponding to x, for minimizing
      mean squared error.
    w: (numpy array of floats) Weights for each (x, y) pair.
    num_segments: (int) Maximum number of segments to fit.
    min_slope: (float) Minimum slope between each adjacent pair of knots. Set to
      0 for a monotone increasing solution, or None for no restriction.
    max_slope: (float) Maximum slope between each adjacent pair of knots. Set to
      0 for a monotone decreasing solution, or None for no restriction.
    bitonic_peak: The x-value at which the bitonic slope changes direction. If
      None, bitonic restrictions are not applied.
    bitonic_concave_down: The direction of bitonic fitting. Used only when
      bitonic_peak is not None.
    required_x_knots: (optional sequence of floats) X-values that must be used
      for knots in the final curve.

  Returns:
    Returns two tuple (x_points, y_points) where x_points (y_points) is the list
    of x-axis (y-axis) knot points of the fit PWL curve.
  """
    utils.expect(len(x) == len(y) == len(w) >= 1)
    utils.expect(num_segments >= 1)
    utils.expect(min_slope is None or max_slope is None
                 or min_slope <= max_slope)
    required_x_knots = [] if required_x_knots is None else list(
        required_x_knots)
    utils.expect(
        len(required_x_knots) <= num_segments + 1,
        'Cannot require more than (num_segments + 1) knots.')

    if len(x_knots) == 1 or np.all(y == y[0]):  # Constant function.
        y_mean = np.average(y, weights=w)
        return [x[0] - 1, x[0]], [y_mean, y_mean]

    solver = _WeightedLeastSquaresPWLSolver(x, y, w, min_slope, max_slope,
                                            bitonic_peak, bitonic_concave_down)
    return _fit_pwl_approx(x_knots, solver.solve, num_segments,
                           required_x_knots)
Esempio n. 9
0
    def __init__(self,
                 x: np.ndarray,
                 y: np.ndarray,
                 w: np.ndarray,
                 min_slope: Optional[float] = None,
                 max_slope: Optional[float] = None):
        """Constructor.

    Args:
      x: (numpy array of floats) X-axis values for minimizing mean squared
        error. Must be unique, and sorted in ascending order.
      y: (numpy array of floats) Y-axis values corresponding to x, for
        minimizing mean squared error.
      w: (numpy array of floats) Weights for each (x, y) pair.
      min_slope: float indicating the minimum slope between each adjacent pair
        of knots. Set to 0 to impose a monotone increasing solution.
      max_slope: float indicating the maximum slope between each adjacent pair
        of knots. Set to 0 to impose a monotone decreasing solution.
    """
        utils.expect(len(x) == len(y) == len(w) >= 1)
        utils.expect((w >= 0).all(), 'weights cannot be negative.')
        utils.expect(min_slope is None or max_slope is None
                     or min_slope <= max_slope)

        sqrt_w = np.sqrt(w, dtype=float)
        self._sqrt_w = sqrt_w.reshape(len(w), 1)
        self._weighted_y = np.array(y, dtype=float, copy=False) * sqrt_w
        self._x = np.array(x, dtype=float, copy=False)
        self._min_slope = min_slope
        self._max_slope = max_slope
Esempio n. 10
0
def _clip_extremes(
        x: np.ndarray, y: np.ndarray, w: np.ndarray,
        pct_to_clip: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Clips the pct_to_clip first and last values by weight."""
    utils.expect(0 <= pct_to_clip < .5)
    if pct_to_clip == 0:
        return x, y, w

    w_cumsum = w.cumsum()
    w_sum = w_cumsum[-1]
    cut_weight = w_sum * pct_to_clip

    # The indices of the first and last nonzero entries after clipping.
    first_nonzero = w_cumsum.searchsorted(cut_weight, side='right')
    last_nonzero = w_cumsum.searchsorted(w_sum - cut_weight, side='left')
    x = x[first_nonzero:last_nonzero + 1]
    y = y[first_nonzero:last_nonzero + 1]
    w = w[first_nonzero:last_nonzero + 1].copy()  # Don't modify the original.

    # Use any leftover cut_weight to reduce the first and last weights.
    w[0] = w_cumsum[first_nonzero] - cut_weight
    w[-1] = w_sum - w_cumsum[last_nonzero - 1] - cut_weight
    return x, y, w
Esempio n. 11
0
def bitonic_regression(sequence, weights=None, convex=True):
    """Returns a bitonic sequence which most closely approximates sequence.

  Args:
    sequence: List of numbers to approximate.
    weights: List of positive weights to use.
    convex: If True, the sequence should be convex, otherwise concave. Defaults
            to True.
  Returns:
    A bitonic sequence approximating the given sequence. Note that the output
    of this function is always a sequence of floats.
  """
    sequence = np.array(sequence, copy=False, dtype=float)
    if len(sequence) <= 1:
        return sequence

    if convex:
        return -bitonic_regression(-sequence, weights, convex=False)

    if weights is None:
        weights = np.ones_like(sequence)
    else:
        weights = np.array(weights, copy=False, dtype=float)
        utils.expect(
            len(weights) == len(sequence),
            'Weights must be same size as sequence.')
        utils.expect((weights > 0).all(), 'Weights must be positive.')

    index, _ = bitonic_peak_and_error(sequence, weights)
    best_prefix = isotonic_regression(sequence[:index],
                                      weights[:index],
                                      increasing=True)
    best_suffix = isotonic_regression(sequence[index:],
                                      weights[index:],
                                      increasing=False)

    return np.concatenate([best_prefix, best_suffix])
Esempio n. 12
0
def fit_pwl_points(
    x_knots: np.ndarray,
    x: np.ndarray,
    y: np.ndarray,
    w: np.ndarray,
    num_segments: int,
    min_slope: Optional[float] = None,
    max_slope: Optional[float] = None
) -> Tuple[Sequence[float], Sequence[float]]:
    """Fits a num_segments segment PWL to the sample points.

  Args:
    x_knots: (numpy array of floats) X-axis knot candidates. Must be unique, and
      sorted in ascending order.
    x: (numpy array of floats) X-axis values for minimizing mean squared error.
      Must be unique, and sorted in ascending order.
    y: (numpy array of floats) Y-axis values corresponding to x, for minimizing
      mean squared error.
    w: (numpy array of floats) Weights for each (x, y) pair.
    num_segments: (int) Maximum number of segments to fit.
    min_slope: (float) Minimum slope between each adjacent pair of knots. Set to
      0 for a monotone increasing solution, or None for no restriction.
    max_slope: (float) Maximum slope between each adjacent pair of knots. Set to
      0 for a monotone decreasing solution, or None for no restriction.

  Returns:
    Returns two tuple (x_points, y_points) where x_points (y_points) is the list
    of x-axis (y-axis) knot points of the fit PWL curve.
  """
    utils.expect(len(x) == len(y) == len(w) >= 1)
    utils.expect(num_segments >= 1)
    utils.expect(min_slope is None or max_slope is None
                 or min_slope <= max_slope)

    if len(x_knots) == 1 or np.all(y == y[0]):  # Constant function.
        y_mean = np.average(y, weights=w)
        return [x[0] - 1, x[0]], [y_mean, y_mean]

    solver = _WeightedLeastSquaresPWLSolver(x, y, w, min_slope, max_slope)
    return _fit_pwl_approx(x_knots, solver.solve, num_segments)
Esempio n. 13
0
    def __init__(self,
                 points: Sequence[Tuple[float, float]],
                 fx: Callable[[np.ndarray], np.ndarray] = transform.identity):
        """Initializer.

    Args:
      points: x,y control points.
      fx: Transform to apply to x values before linear interpolation.
    """
        utils.expect(
            len(points) >= 2, 'A PWLCurve must have at least two knots.')
        curve_xs, curve_ys = zip(*points)
        curve_xs, curve_ys = (np.asarray(curve_xs, dtype=float),
                              np.asarray(curve_ys, dtype=float))
        utils.expect(
            len(set(curve_xs)) == len(curve_xs),
            'Curve knot xs must be unique.')
        utils.expect((np.sort(curve_xs) == curve_xs).all(),
                     'Curve knot xs must be ordered.')
        self._curve_xs = curve_xs
        self._curve_ys = curve_ys
        self._fx = fx
Esempio n. 14
0
  def test_expect_raises_when_false(self):
    with self.assertRaises(ValueError):
      utils.expect(False)

    with self.assertRaisesRegex(ValueError, 'Value is False'):
      utils.expect(False, 'Value is False.')
Esempio n. 15
0
 def test_expect_does_nothing_when_true(self):
   utils.expect(True)
   utils.expect(True, 'True should be True.')
Esempio n. 16
0
def sort_and_sample(
        x: Sequence[float],
        y: Sequence[float],
        w: Optional[Sequence[float]],
        downsample_to: float = 1e6
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Samples and sorts the data to fit a PWLCurve on.

  Samples each point with equal likelihood, once or zero times per point. Weight
  is not considered when sampling. For performance reasons, the precise number
  of final points is not guaranteed.

  Args:
    x: (Sequence of floats) The independent variable.
    y: (Sequence of floats) The dependent variable.
    w: (None or Sequence of floats) The weights of data points. Weights are NOT
      used in downsampling.
    downsample_to: (int or float) The approximate number of samples to take.

  Raises:
    ValueError: invalid input.

  Returns:
    A triple (sorted_x, y, w) of numpy arrays representing the dependent
    variable in sorted order, the independent variable, and the weights
    respectively.
  """
    x = np.array(x, copy=False)
    y = np.array(y, copy=False)
    if w is None:
        w = np.ones_like(x)
    else:
        w = np.array(w, copy=False)
        utils.expect((w > 0).all(), 'Weights must be positive.')

    utils.expect(len(x) == len(y) == len(w) >= 1)
    utils.expect(np.isfinite(x).all(), 'x-values must all be finite.')
    utils.expect(np.isfinite(y).all(), 'y-values must all be finite.')
    utils.expect(np.isfinite(w).all(), 'w-values must all be finite.')

    # Downsample to a manageable number of points to limit runtime.
    if len(x) > downsample_to * 1.01:
        np.random.seed(125)
        # Select each xyw with probability (downsample_to / len(x)) to yield
        # approximately downsample_to selections.
        fraction_kept = float(downsample_to) / len(x)
        mask = np.random.sample(size=len(x)) < fraction_kept
        x, y, w = x[mask], y[mask], w[mask]

    # Sort the points by x if any are out of order.
    if (x[1:] < x[:-1]).any():
        point_order = np.argsort(x)
        x, y, w = x[point_order], y[point_order], w[point_order]

    # Use float64 for precision.
    x = x.astype(float, copy=False)
    y = y.astype(float, copy=False)
    w = w.astype(float, copy=False)

    # Fuse points with the same x, so that all xs become unique.
    x, y, w = utils.fuse_sorted_points(x, y, w)
    return x, y, w
Esempio n. 17
0
    def __init__(self,
                 x: np.ndarray,
                 y: np.ndarray,
                 w: np.ndarray,
                 min_slope: Optional[float] = None,
                 max_slope: Optional[float] = None,
                 bitonic_peak: Optional[float] = None,
                 bitonic_concave_down: Optional[bool] = None):
        """Constructor.

    Args:
      x: X-axis floats for minimizing mean squared error. Must be unique, and
        sorted in ascending order.
      y: Y-axis floats corresponding to x, for minimizing mean squared error.
      w: Weights for each (x, y) pair. Positive floats.
      min_slope: float indicating the minimum slope between each adjacent pair
        of knots. Set to 0 to impose a monotone increasing solution.
      max_slope: float indicating the maximum slope between each adjacent pair
        of knots. Set to 0 to impose a monotone decreasing solution.
      bitonic_peak: float indicating the x-value at which the bitonic slope
        changes direction. If None, bitonic restrictions are not applied.
      bitonic_concave_down: bool indicating the direction of bitonic fitting.
        Used only when bitonic_peak is not None.
    """
        utils.expect(len(x) == len(y) == len(w) >= 1)
        utils.expect((w >= 0).all(), 'weights cannot be negative.')
        utils.expect(min_slope is None or max_slope is None
                     or min_slope <= max_slope)
        utils.expect(
            (bitonic_peak is None and bitonic_concave_down is None)
            or (bitonic_peak is not None and bitonic_concave_down is not None),
            'bitonic solver requires both bitonic_peak and bitonic_concave_down.'
        )
        utils.expect(
            bitonic_peak is None or (min_slope is None or min_slope < 0),
            'cannot learn bitonic solution when min_slope >= 0')
        utils.expect(
            bitonic_peak is None or (max_slope is None or max_slope > 0),
            'cannot learn bitonic solution when max_slope <= 0')

        sqrt_w = np.sqrt(w, dtype=float)
        self._sqrt_w = sqrt_w.reshape(len(w), 1)
        self._weighted_y = np.array(y, dtype=float, copy=False) * sqrt_w
        self._x = np.array(x, dtype=float, copy=False)
        self._min_slope = min_slope
        self._max_slope = max_slope
        self._bitonic_peak = bitonic_peak
        self._bitonic_concave_down = bitonic_concave_down
        self._is_unconstrained = (self._min_slope is None
                                  and self._max_slope is None
                                  and self._bitonic_peak is None)
Esempio n. 18
0
def fit_pwl(x: Sequence[float],
            y: Sequence[float],
            w: Optional[Sequence[float]] = None,
            num_segments: int = 3,
            num_samples: int = 100,
            mono: Union[MonoType, bool] = MonoType.mono,
            min_slope: Optional[float] = None,
            max_slope: Optional[float] = None,
            fx: Optional[Callable[[np.ndarray], np.ndarray]] = None,
            learn_ends: bool = True) -> pwlcurve.PWLCurve:
    """Fits a PWLCurve from x to y, minimizing weighted MSE.

  Attempts to find a piecewise linear curve which is as close to ys as possible,
  in a least squares sense.

  ~O(len(x) + qlog(q) + (num_samples^2)(num_segments^3)) time complexity, where
  q is ~min(10**6, len(x)). The len(x) term occurs because of downsampling
  to q points. The qlog(q) term comes from sorting after downsampling. The other
  term comes from fit_pwl_points, which greedily searches for the best
  combination of knots and solves a constrained linear least squares expression
  for each.

  Args:
    x: (Sequence of floats) independent variable.
    y: (Sequence of floats) dependent variable.
    w: (None or Sequence of floats) the weights on data points.
    num_segments: (positive int) Number of linear segments. More segments
      increases quality at the cost of complexity.
    num_samples: (positive int) Number of potential knot locations to try for
      the PWL curve. More samples improves fit quality, but slows fitting. At
      100 samples, fit_pwl runs in 1-2 seconds. At 1000 samples, it runs in
      under a minute. At 10,000 samples, expect an hour.
    mono: (MonoType enum) Restrictions to apply in curve fitting, with
      monotonicity as the default. See MonoType for all options.
    min_slope: (None or float) Minimum slope between each adjacent pair of
      knots. Set to 0 for a monotone increasing solution.
    max_slope: (None or float) Maximum slope between each adjacent pair of
      knots. Set to 0 for a monotone decreasing solution.
    fx: (None or a strictly increasing 1D function) User-specified transform on
      x, to apply before piecewise-linear curve fitting. If None, fit_pwl
      chooses a transform using a heuristic. To specify fitting with no
      transform, pass in transform.identity.
    learn_ends: (boolean) Whether to learn x-values for the curve's endpoints.
      Learning endpoints allows for better-fitting curves with the same number
      of segments. If False, fit_pwl forces the curve to use min(x) and max(x)
      as knots, which constrains the solution space.

  Returns:
    The fit curve.
  """
    utils.expect(num_segments > 0, 'Cannot fit %d segment PWL' % num_segments)
    utils.expect(num_samples > num_segments,
                 'num_samples must be at least num_segments + 1')

    x, y, w = sort_and_sample(x, y, w)
    if fx is None:
        fx = transform.find_best_transform(x, y, w)

    original_x = x
    trans_x = fx(x)
    utils.expect(
        np.isfinite(trans_x[[0, -1]]).all(), 'Transform must be defined on x.')

    # Pick a subset of x to use as candidate knots, and compress x, y, w around
    # those candidate knots.
    x_knots, x, y, w = (linear_condense.sample_condense_points(
        trans_x, y, w, num_samples))

    if mono == MonoType.mono:
        min_slope, max_slope = _get_mono_slope_bounds(y, w, min_slope,
                                                      max_slope)

    bitonic_peak, bitonic_concave_down = _bitonic_peak_and_direction(
        x, y, w, mono)

    # Fit a piecewise-linear curve in the transformed space.
    required_knots = None if learn_ends else x_knots[[0, -1]]
    x_pnts, y_pnts = fit_pwl_points(x_knots, x, y, w, num_segments, min_slope,
                                    max_slope, bitonic_peak,
                                    bitonic_concave_down, required_knots)

    # Recover the control point xs in the pre-transform space.
    x_pnts = original_x[trans_x.searchsorted(x_pnts)]
    if np.all(y_pnts == y_pnts[0]):  # The curve is constant.
        curve_points = [(x_pnts[0] - 1, y_pnts[0]), (x_pnts[0], y_pnts[0])]
    else:
        curve_points = list(zip(x_pnts, y_pnts))
    return pwlcurve.PWLCurve(curve_points, fx)