chromium/chrome/browser/ash/power/auto_screen_brightness/gaussian_trainer.cc

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/ash/power/auto_screen_brightness/gaussian_trainer.h"

#include <algorithm>
#include <cmath>
#include <limits>

#include "ash/constants/ash_features.h"
#include "base/logging.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "chrome/browser/ash/power/auto_screen_brightness/utils.h"

namespace ash {
namespace power {
namespace auto_screen_brightness {

namespace {

// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
// Logs whether a new brightness exceeded the reasonable distance from the old
// brightness. A reasonable distance is defined by the params
// |brightness_step_size| and |model_brightness_step_size|.
enum class BoundedBrightnessChange {
  // User's chosen new brightness is within their [lower_bound, upper_bound].
  kUserWithinBounds = 0,
  // Target brightness has a reasonable distance model's predicted brightness.
  kModelWithinBounds = 1,
  // User's chosen new brightness is below their lower bound.
  kUserLower = 2,
  // User's chosen new brightness is above their upper bound.
  kUserUpper = 3,
  // Target brightness is below model's predicted brightness and exceeded the
  // bound.
  kModelLower = 4,
  // Target brightness is above model's predicted brightness and exceeded the
  // bound.
  kModelUpper = 5,
  kMaxValue = kModelUpper
};

// Returns a |BoundedBrightnessChange| to be logged to UMA.
// |is_lower_bound_exceeded| is nullopt if the new brightness is within the
// bounds.
BoundedBrightnessChange GetBoundedBrightnessChange(
    std::optional<bool> is_lower_bound_exceeded,
    bool is_user) {
  if (!is_lower_bound_exceeded.has_value()) {
    if (is_user) {
      return BoundedBrightnessChange::kUserWithinBounds;
    }
    return BoundedBrightnessChange::kModelWithinBounds;
  }

  if (*is_lower_bound_exceeded) {
    if (is_user) {
      return BoundedBrightnessChange::kUserLower;
    }
    return BoundedBrightnessChange::kModelLower;
  }

  if (is_user) {
    return BoundedBrightnessChange::kUserUpper;
  }
  return BoundedBrightnessChange::kModelUpper;
}

constexpr double kTol = 1e-10;

// Calculates lower bound from |reference_brightness| using the min of
// 1. Division by a scaling factor and
// 2. Subtraction of an offset.
double BrightnessLowerBound(double reference_brightness,
                            double scale,
                            double offset) {
  DCHECK_GT(scale, 0.0);
  DCHECK_GE(offset, 0.0);

  return std::clamp(reference_brightness / scale, 0.0,
                     std::max(reference_brightness - offset, 0.0));
}

// Calculates upper bound from |reference_brightness| using the max of
// 1. Multiplication by a scaling factor and
// 2. Addition of an offset.
// The upper bound is also capped at 100.0.
double BrightnessUpperBound(double reference_brightness,
                            double scale,
                            double offset) {
  DCHECK_GT(scale, 0.0);
  DCHECK_GE(offset, 0.0);

  return std::clamp(reference_brightness * scale,
                     std::min(reference_brightness + offset, 100.0), 100.0);
}

// Returns whether |brightness| is an outlier from a |reference_brightness|.
bool IsBrightnessOutlier(double brightness,
                         double reference_brightness,
                         const GaussianTrainer::Params& params) {
  DCHECK_GE(reference_brightness, 0.0);
  DCHECK_LE(reference_brightness, 100.0);
  return brightness < BrightnessLowerBound(reference_brightness,
                                           params.brightness_bound_scale,
                                           params.brightness_bound_offset) ||
         brightness > BrightnessUpperBound(reference_brightness,
                                           params.brightness_bound_scale,
                                           params.brightness_bound_offset);
}

// User's selected |brightness_new| may not be the value that the user needs for
// various reasons, e.g. they could overshoot. Hence this function calculates
// the bounded brightness change based on a heuristic magnitude. The new
// brightness is bounded within a factor of 1+|brightness_step_size| from
// |brightness_old|.
double BoundedBrightnessAdjustment(double brightness_old,
                                   double brightness_new,
                                   double brightness_step_size,
                                   bool is_user) {
  const double lower_bound = brightness_old / (1.0 + brightness_step_size);
  const double upper_bound = brightness_old * (1.0 + brightness_step_size);

  const bool exceeded_upper = brightness_new > upper_bound;
  const bool exceeded_lower = brightness_new < lower_bound;

  const BoundedBrightnessChange change = GetBoundedBrightnessChange(
      exceeded_lower || exceeded_upper ? std::optional<bool>(exceeded_lower)
                                       : std::nullopt,
      is_user);
  UMA_HISTOGRAM_ENUMERATION(
      "AutoScreenBrightness.ModelTraining.BrightnessChange", change);

  return std::clamp(brightness_new, lower_bound, upper_bound) - brightness_old;
}

// Calculates recommended brightness change, given old brightness, user's
// selected new brghtness and model's predicted brightness.
double ModelPredictionAdjustment(double brightness_old,
                                 double brightness_new,
                                 double model_brightness,
                                 const GaussianTrainer::Params& params) {
  DCHECK_GE(brightness_old, 0.0);
  DCHECK_LE(brightness_old, 100.0);
  DCHECK_GE(brightness_new, 0.0);
  DCHECK_LE(brightness_new, 100.0);
  DCHECK_GE(model_brightness, 0.0);
  DCHECK_LE(model_brightness, 100.0);

  const double bounded_user_adjustment = BoundedBrightnessAdjustment(
      brightness_old, brightness_new, params.brightness_step_size,
      true /* is_user */);

  DCHECK_GE(bounded_user_adjustment, -100.0);
  DCHECK_LE(bounded_user_adjustment, 100.0);

  const double target_brightness = brightness_old + bounded_user_adjustment;
  DCHECK_GE(target_brightness, 0.0);
  DCHECK_LE(target_brightness, 100.0);

  // Check if model prediction and user adjustment are consistent.
  const bool is_consistent =
      (model_brightness >= target_brightness && bounded_user_adjustment >= 0) ||
      (model_brightness <= target_brightness && bounded_user_adjustment <= 0);
  UMA_HISTOGRAM_BOOLEAN(
      "AutoScreenBrightness.ModelTraining.ModelUserConsistent", is_consistent);

  // If model's prediction is consistent with user's selection, then no
  // brightness change will be necessary.
  if (is_consistent) {
    return 0.0;
  }

  // Model prediction is incorrect, calculate the change we need to make by
  // treating |model_brightness| as the old brightness and |target_brightness|
  // as the new brightness.
  return BoundedBrightnessAdjustment(model_brightness, target_brightness,
                                     params.model_brightness_step_size,
                                     false /* is_user */);
}

double Gaussian(double x, double sigma) {
  double xs = x / sigma;
  return std::exp(-xs * xs);
}

void LogModelCurveError(double error, bool model_updated) {
  DCHECK_GE(error, 0.0);
  const std::string histogram_name =
      std::string("AutoScreenBrightness.ModelTraining.Inaccuracy.") +
      (model_updated ? "Update" : "NoUpdate");
  base::UmaHistogramPercentageObsoleteDoNotUse(histogram_name,
                                               std::round(error));
}

}  // namespace

TrainingResult::TrainingResult() = default;
TrainingResult::TrainingResult(
    const std::optional<MonotoneCubicSpline>& new_curve,
    double error)
    : new_curve(new_curve), error(error) {}

TrainingResult::TrainingResult(const TrainingResult& result) = default;
TrainingResult::~TrainingResult() = default;

GaussianTrainer::Params::Params() = default;

GaussianTrainer::GaussianTrainer() {
  params_.brightness_bound_scale = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "brightness_bound_scale",
      params_.brightness_bound_scale);
  if (params_.brightness_bound_scale <= 0.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.brightness_bound_offset = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "brightness_bound_offset",
      params_.brightness_bound_offset);
  if (params_.brightness_bound_offset < 0.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.brightness_step_size = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "brightness_step_size",
      params_.brightness_step_size);
  if (params_.brightness_step_size <= 0.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.model_brightness_step_size = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "model_brightness_step_size",
      params_.model_brightness_step_size);
  if (params_.model_brightness_step_size <= 0.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.sigma = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "sigma", params_.sigma);
  if (params_.sigma <= 0.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.low_log_lux_threshold = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "low_log_lux_threshold",
      params_.low_log_lux_threshold);
  params_.high_log_lux_threshold = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "high_log_lux_threshold",
      params_.high_log_lux_threshold);
  if (params_.low_log_lux_threshold >= params_.high_log_lux_threshold) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.min_grad_low_lux = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "min_grad_low_lux",
      params_.min_grad_low_lux);
  params_.min_grad_high_lux = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "min_grad_high_lux",
      params_.min_grad_high_lux);

  params_.min_grad = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "min_grad", params_.min_grad);
  params_.max_grad = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "max_grad", params_.max_grad);

  if (params_.min_grad_low_lux < 0.0 || params_.min_grad_low_lux >= 1.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  if (params_.min_grad_high_lux < 0.0 || params_.min_grad_high_lux >= 1.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  if (params_.min_grad < 0.0 || params_.min_grad >= 1.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  if (params_.min_grad < params_.min_grad_low_lux) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  if (params_.min_grad < params_.min_grad_high_lux) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  if (params_.max_grad < 1.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }

  params_.min_brightness = GetFieldTrialParamByFeatureAsDouble(
      features::kAutoScreenBrightness, "min_brightness",
      params_.min_brightness);
  if (params_.min_brightness < 0.0) {
    valid_params_ = false;
    LogParameterError(ParameterError::kModelError);
    return;
  }
}

GaussianTrainer::~GaussianTrainer() = default;

bool GaussianTrainer::HasValidConfiguration() const {
  return valid_params_;
}

bool GaussianTrainer::SetInitialCurves(
    const MonotoneCubicSpline& global_curve,
    const MonotoneCubicSpline& current_curve) {
  DCHECK(valid_params_);

  // This function could be called again if the caller wants to reset the
  // curves.
  global_curve_.emplace(global_curve);
  current_curve_.emplace(current_curve);

  ambient_log_lux_ = current_curve_->GetControlPointsX();
  brightness_ = current_curve_->GetControlPointsY();
  const size_t num_points = ambient_log_lux_.size();

  // Global curve and personal curve should have the same ambient log lux.
  const std::vector<double> global_log_lux = global_curve_->GetControlPointsX();
  DCHECK_EQ(global_log_lux.size(), num_points);

  for (size_t i = 0; i < num_points; ++i) {
    DCHECK_LE(std::abs(global_log_lux[i] - ambient_log_lux_[i]), kTol);
  }

  // Calculate |min_ratios_| and |max_ratios_| from global curve.
  min_ratios_.resize(num_points - 1);
  max_ratios_.resize(num_points - 1);
  const std::vector<double> global_brightness =
      global_curve_->GetControlPointsY();

  // TODO(jiameng): may revise to allow 0 as a control point.
  DCHECK_GT(global_brightness[0], 0);

  for (size_t i = 0; i < num_points - 1; ++i) {
    double min_grad = params_.min_grad;
    if (global_log_lux[i] < params_.low_log_lux_threshold) {
      min_grad = params_.min_grad_low_lux;
    } else if (global_log_lux[i] > params_.high_log_lux_threshold) {
      min_grad = params_.min_grad_high_lux;
    }

    const double ratio = global_brightness[i + 1] / global_brightness[i];
    DCHECK_GE(ratio, 1);
    min_ratios_[i] = std::pow(ratio, min_grad);
    max_ratios_[i] = std::pow(ratio, params_.max_grad);
  }

  if (!IsInitialPersonalCurveValid()) {
    // Use global curve instead if personal curve isn't valid.
    current_curve_.emplace(global_curve);
    brightness_ = current_curve_->GetControlPointsY();
    return false;
  }

  return true;
}

MonotoneCubicSpline GaussianTrainer::GetGlobalCurve() const {
  DCHECK(valid_params_);
  DCHECK(global_curve_);
  return *global_curve_;
}

MonotoneCubicSpline GaussianTrainer::GetCurrentCurve() const {
  DCHECK(valid_params_);
  DCHECK(current_curve_);
  return *current_curve_;
}

TrainingResult GaussianTrainer::Train(
    const std::vector<TrainingDataPoint>& data) {
  DCHECK(global_curve_);
  DCHECK(current_curve_);
  DCHECK(!data.empty());

  for (const auto& data_point : data) {
    AdjustCurveWithSingleDataPoint(data_point);
  }

  if (!need_to_update_curve_) {
    const double error = CalculateCurveError(data);
    LogModelCurveError(error, false /* model_updated */);
    return TrainingResult(std::nullopt, error);
  }

  need_to_update_curve_ = false;

  const auto new_curve = MonotoneCubicSpline::CreateMonotoneCubicSpline(
      ambient_log_lux_, brightness_);
  if (!new_curve) {
    return TrainingResult(std::nullopt, 0 /* error */);
  }

  current_curve_ = new_curve;

  const double error = CalculateCurveError(data);
  LogModelCurveError(error, true /* model_updated */);
  return TrainingResult(current_curve_, error);
}

bool GaussianTrainer::IsInitialPersonalCurveValid() const {
  // |global_curve_| is valid by construction.
  if (*global_curve_ == *current_curve_)
    return true;

  for (size_t i = 0; i < brightness_.size() - 1; ++i) {
    const double ratio = brightness_[i + 1] / brightness_[i];
    if (ratio < min_ratios_[i] || ratio > max_ratios_[i])
      return false;
  }

  return true;
}

void GaussianTrainer::AdjustCurveWithSingleDataPoint(
    const TrainingDataPoint& data) {
  const double brightness_global =
      global_curve_->Interpolate(data.ambient_log_lux);

  // Check if this |data| is an outlier and should be ignored. It's an outlier
  // if its original/old brightness is too far off from the brightness as
  // predicted by the global curve. This assumes the global curve is reasonably
  // accurate.
  const bool is_brightness_outlier =
      IsBrightnessOutlier(data.brightness_old, brightness_global, params_);
  UMA_HISTOGRAM_BOOLEAN("AutoScreenBrightness.ModelTraining.BrightnessOutlier",
                        is_brightness_outlier);

  if (is_brightness_outlier) {
    return;
  }

  // Calculate how much adjustment we need to make to the current personal
  // curve at |data.ambient_log_lux|.
  const double model_brightness =
      current_curve_->Interpolate(data.ambient_log_lux);
  const double brightness_adjustment = ModelPredictionAdjustment(
      data.brightness_old, data.brightness_new, model_brightness, params_);

  if (std::abs(brightness_adjustment) <= kTol)
    return;

  need_to_update_curve_ = true;

  // Index of the log-lux in |ambient_log_lux_| that's closest to
  // |data.ambient_log_lux|.
  size_t center_index = 0;
  double min_dist = std::numeric_limits<double>::max();
  for (size_t i = 0; i < ambient_log_lux_.size(); ++i) {
    // Adjust brightness of each control point in the current brightness curve.
    const double dist = std::abs(data.ambient_log_lux - ambient_log_lux_[i]);
    brightness_[i] += brightness_adjustment * Gaussian(dist, params_.sigma);

    if (dist < min_dist) {
      center_index = i;
      min_dist = dist;
    }
  }

  EnforceMonotonicity(center_index);
}

void GaussianTrainer::EnforceMonotonicity(size_t center_index) {
  DCHECK_LT(center_index, ambient_log_lux_.size());
  brightness_[center_index] =
      std::clamp(brightness_[center_index], params_.min_brightness, 100.0);

  // Updates control points to the left of |center_index| so that brightness
  // values satisfy min/max ratio requirement.
  for (size_t i = center_index; i > 0; --i) {
    const double min_value = brightness_[i] / max_ratios_[i - 1];
    const double max_value = brightness_[i] / min_ratios_[i - 1];
    brightness_[i - 1] = std::clamp(brightness_[i - 1], min_value, max_value);
    if (brightness_[i - 1] > 100.0) {
      brightness_[i - 1] = 100.0;
    }
  }

  // Updates control points to the right of |center_index| so that brightness
  // values satisfy min/max ratio requirement.
  for (size_t i = center_index; i < ambient_log_lux_.size() - 1; ++i) {
    const double min_value = brightness_[i] * min_ratios_[i];
    const double max_value = brightness_[i] * max_ratios_[i];
    brightness_[i + 1] = std::clamp(brightness_[i + 1], min_value, max_value);
    if (brightness_[i + 1] > 100.0) {
      brightness_[i + 1] = 100.0;
    }
  }

#ifndef NDEBUG
  // Check that final |brightness_| array is monotonic across whole range and
  // each value is in [0, 100].
  for (size_t i = 0; i < ambient_log_lux_.size() - 1; ++i) {
    DCHECK_GE(brightness_[i], 0);
    DCHECK_LE(brightness_[i], 100);
    DCHECK_LE(brightness_[i], brightness_[i + 1]);
  }

  DCHECK_GE(brightness_.back(), 0);
  DCHECK_LE(brightness_.back(), 100);
#endif
}

double GaussianTrainer::CalculateCurveError(
    const std::vector<TrainingDataPoint>& data) const {
  DCHECK(current_curve_);
  double error = 0.0;
  for (const auto& data_point : data) {
    error += std::abs(data_point.brightness_new -
                      current_curve_->Interpolate(data_point.ambient_log_lux));
  }
  return error / data.size();
}

}  // namespace auto_screen_brightness
}  // namespace power
}  // namespace ash