blob: 9866c5aac7c4efb7edc9b978c35a76f13db4fb38 [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "testing/perf/confidence/ratio_bootstrap_estimator.h"
#include <limits>
#define _USE_MATH_DEFINES // Needed to get M_SQRT1_2 on Windows.
#include <math.h>
#include <algorithm>
#include <memory>
#include <vector>
#ifdef UNSAFE_BUFFERS_BUILD
// Not used with untrusted inputs.
#pragma allow_unsafe_buffers
#endif
using std::lower_bound;
using std::min;
using std::numeric_limits;
using std::sort;
using std::unique_ptr;
using std::vector;
// Inverse normal CDF, e.g. InverseNormalCDF(0.975) ~= 1.96
// (a 95% CI will cover +/- 1.96 standard deviations from the mean).
//
// For some reason, C has erf() in its standard library, but not its inverse,
// so we have to build it ourselves (which is a bit annoying, since we only
// really want it to convert the confidence interval quantiles!). This is
// nontrivial, but fortunately, others have figured it out. This is an
// implementation of “Algorithm AS 241: The Percentage Points of the Normal
// Distribution” (Wichura), and is roughly the same as was used in GNU R
// until recently. We don't need extreme precision, so we've only used the
// version that is accurate to about seven decimal digits (the other one
// is pretty much the same, just with even more constants).
double RatioBootstrapEstimator::InverseNormalCDF(double p) {
double q = p - 0.5;
if (fabs(q) < 0.425) {
const double a0 = 3.3871327179e0;
const double a1 = 5.0434271938e1;
const double a2 = 1.5929113202e2;
const double a3 = 5.9109374720e1;
const double b1 = 1.7895169469e1;
const double b2 = 7.8757757664e1;
const double b3 = 6.7187563600e1;
double r = 0.180625 - q * q;
return q * (((a3 * r + a2) * r + a1) * r + a0) /
(((b3 * r + b2) * r + b1) * r + 1.0);
} else {
double r = (q < 0) ? p : 1.0 - p;
if (r < 0.0) {
return numeric_limits<double>::quiet_NaN();
}
r = sqrt(-log(r));
double ret;
if (r < 5.0) {
const double c0 = 1.4234372777e0;
const double c1 = 2.7568153900e0;
const double c2 = 1.3067284816e0;
const double c3 = 1.7023821103e-1;
const double d1 = 7.3700164250e-1;
const double d2 = 1.2021132975e-1;
r -= 1.6;
ret = (((c3 * r + c2) * r + c1) * r + c0) / ((d2 * r + d1) * r + 1.0);
} else {
const double e0 = 6.6579051150e0;
const double e1 = 3.0812263860e0;
const double e2 = 4.2868294337e-1;
const double e3 = 1.7337203997e-2;
const double f1 = 2.4197894225e-1;
const double f2 = 1.2258202635e-2;
r -= 5.0;
ret = (((e3 * r + e2) * r + e1) * r + e0) / ((f2 * r + f1) * r + 1.0);
}
return (q < 0) ? -ret : ret;
}
}
namespace {
// Normal (Gaussian) CDF, e.g. NormCRF(1.96) ~= 0.975
// (+/- 1.96 standard deviations would cover a 95% CI).
double NormalCDF(double q) {
return 0.5 * erfc(-q * M_SQRT1_2);
}
// Compute percentiles of the bootstrap distribution (the inverse of G).
// We estimate G by Ĝ, the bootstrap estimate of G (text above eq. 2.9
// in the paper). Note that unlike bcajack, we interpolate between values
// to get slightly better accuracy.
double ComputeBCa(const double* estimates,
size_t num_estimates,
double alpha,
double z0,
double a) {
double z_alpha = RatioBootstrapEstimator::InverseNormalCDF(alpha);
// Eq. (2.2); the basic BCa formula.
double q = NormalCDF(z0 + (z0 + z_alpha) / (1 - a * (z0 + z_alpha)));
double index = q * (num_estimates - 1);
int base_index = index;
if (base_index == static_cast<int>(num_estimates - 1)) {
// The edge of the CDF; note that R would warn in this case.
return estimates[base_index];
}
double frac = index - base_index;
return estimates[base_index] +
frac * (estimates[base_index + 1] - estimates[base_index]);
}
// Calculate Ĝ (the fraction of estimates that are less than search-value).
double FindCDF(const double* estimates,
size_t num_estimates,
double search_val) {
// Find first x where x >= search_val.
auto it = lower_bound(estimates, estimates + num_estimates, search_val);
if (it == estimates + num_estimates) {
// All values are less than search_val.
// Note that R warns in this case.
return 1.0;
}
unsigned index = std::distance(estimates, it);
if (index == 0) {
// All values are >= search_val.
// Note that R warns in this case.
return 0.0;
}
// TODO(sesse): Consider whether we should interpolate here, like in
// compute_bca().
return index / double(num_estimates);
}
} // namespace
// Find the ratio estimate over all values except for the one at skip_index,
// i.e., leave-one-out. (If skip_index == -1 or similar, simply compute over
// all values.) This is used in the jackknife estimate for the acceleration.
double RatioBootstrapEstimator::EstimateRatioExcept(
const vector<RatioBootstrapEstimator::Sample>& x,
int skip_index) {
double before = 0.0, after = 0.0;
for (unsigned i = 0; i < x.size(); ++i) {
if (static_cast<int>(i) == skip_index) {
continue;
}
before += x[i].before;
after += x[i].after;
}
return before / after;
}
// Similar, for the geometric mean across all the data sets.
double RatioBootstrapEstimator::EstimateGeometricMeanExcept(
const vector<vector<RatioBootstrapEstimator::Sample>>& x,
int skip_index) {
double geometric_mean = 1.0;
for (const auto& samples : x) {
geometric_mean *= EstimateRatioExcept(samples, skip_index);
}
return pow(geometric_mean, 1.0 / x.size());
}
vector<RatioBootstrapEstimator::Estimate>
RatioBootstrapEstimator::ComputeRatioEstimates(
const vector<vector<RatioBootstrapEstimator::Sample>>& data,
unsigned num_resamples,
double confidence_level,
bool compute_geometric_mean) {
if (data.empty() || num_resamples < 10 || confidence_level <= 0.0 ||
confidence_level >= 1.0) {
return {};
}
unsigned num_observations = numeric_limits<unsigned>::max();
for (const vector<Sample>& samples : data) {
num_observations = min<unsigned>(num_observations, samples.size());
}
// Allocate some memory for temporaries that we need.
unsigned num_dimensions = data.size();
unique_ptr<double[]> before(new double[num_dimensions]);
unique_ptr<double[]> after(new double[num_dimensions]);
unique_ptr<double[]> all_estimates(
new double[(num_dimensions + compute_geometric_mean) * num_resamples]);
// Do our bootstrap resampling. Note that we can sample independently
// from the numerator and denumerator (which the R packages cannot do);
// this makes sense, because they are not pairs. This allows us to sometimes
// get slightly narrower confidence intervals.
//
// When computing the geometric mean, we could perhaps consider doing
// similar independent sampling across the various data sets, but we
// currently don't do so.
for (unsigned i = 0; i < num_resamples; ++i) {
for (unsigned d = 0; d < num_dimensions; ++d) {
before[d] = 0.0;
after[d] = 0.0;
}
for (unsigned j = 0; j < num_observations; ++j) {
unsigned r1 = gen_();
unsigned r2 = gen_();
// NOTE: The bias from the modulo here should be insignificant.
for (unsigned d = 0; d < num_dimensions; ++d) {
unsigned index1 = r1 % data[d].size();
unsigned index2 = r2 % data[d].size();
before[d] += data[d][index1].before;
after[d] += data[d][index2].after;
}
}
double geometric_mean = 1.0;
for (unsigned d = 0; d < num_dimensions; ++d) {
double ratio = before[d] / after[d];
all_estimates[d * num_resamples + i] = ratio;
geometric_mean *= ratio;
}
if (compute_geometric_mean) {
all_estimates[num_dimensions * num_resamples + i] =
pow(geometric_mean, 1.0 / num_dimensions);
}
}
// Make our point estimates.
vector<Estimate> result;
for (unsigned d = 0; d < num_dimensions + compute_geometric_mean; ++d) {
bool is_geometric_mean = (d == num_dimensions);
double* estimates = &all_estimates[d * num_resamples];
// FindCDF() and others expect sorted data.
sort(estimates, estimates + num_resamples);
// Make our point estimate.
double point_estimate = is_geometric_mean
? EstimateGeometricMeanExcept(data, -1)
: EstimateRatioExcept(data[d], -1);
// Compute bias correction, Eq. (2.9).
double z0 =
InverseNormalCDF(FindCDF(estimates, num_resamples, point_estimate));
// Compute acceleration. This is Eq. (3.11), except that there seems
// to be a typo; the sign seems to be flipped compared to bcajack,
// so we correct that (see line 148 of bcajack.R). Note that bcajack
// uses the average-of-averages instead of the point estimate,
// which doesn't seem to match the paper, but the difference is
// completely insigificant in practice.
double sum_d_squared = 0.0;
double sum_d_cubed = 0.0;
if (is_geometric_mean) {
// NOTE: If there are differing numbers of samples in the different
// data series, this will be ever so slightly off, but the effect
// should hopefully be small.
for (unsigned i = 0; i < num_observations; ++i) {
double dd = point_estimate - EstimateGeometricMeanExcept(data, i);
sum_d_squared += dd * dd;
sum_d_cubed += dd * dd * dd;
}
} else {
for (unsigned i = 0; i < data[d].size(); ++i) {
double dd = point_estimate - EstimateRatioExcept(data[d], i);
sum_d_squared += dd * dd;
sum_d_cubed += dd * dd * dd;
}
}
double a = sum_d_cubed /
(6.0 * sqrt(sum_d_squared * sum_d_squared * sum_d_squared));
double alpha = 0.5 * (1 - confidence_level);
Estimate est;
est.point_estimate = point_estimate;
est.lower = ComputeBCa(estimates, num_resamples, alpha, z0, a);
est.upper = ComputeBCa(estimates, num_resamples, 1.0 - alpha, z0, a);
result.push_back(est);
}
return result;
}