Мне нужно вычислить среднее геометрическое большого набора чисел, значения которых априори не ограничены. Наивный путь был бы
double geometric_mean(std::vector<double> const&data) // failure
{
auto product = 1.0;
for(auto x:data) product *= x;
return std::pow(product,1.0/data.size());
}
Тем не менее, это может не сработать из-за недостаточного или переполнения накопленного product
(нота: long double
на самом деле не избежать этой проблемы). Итак, следующий вариант — суммировать логарифмы:
double geometric_mean(std::vector<double> const&data)
{
auto sumlog = 0.0;
for(auto x:data) sum_log += std::log(x);
return std::exp(sum_log/data.size());
}
Это работает, но звонит std::log()
для каждого элемента, который потенциально медленный. Могу ли я избежать этого? Например, отслеживая (эквивалент) показателя и мантиссы накопленного product
по отдельности?
Решение «разделить экспоненту и мантиссу»:
double geometric_mean(std::vector<double> const & data)
{
double m = 1.0;
long long ex = 0;
double invN = 1.0 / data.size();
for (double x : data)
{
int i;
double f1 = std::frexp(x,&i);
m*=f1;
ex+=i;
}
return std::pow( std::numeric_limits<double>::radix,ex * invN) * std::pow(m,invN);
}
Если вы обеспокоены тем, что ex
может переполниться, вы можете определить его как двойной вместо long long
и умножить на invN
на каждом шагу, но вы можете потерять много точности с этим подходом.
РЕДАКТИРОВАТЬ Для больших входных данных мы можем разделить вычисления на несколько сегментов:
double geometric_mean(std::vector<double> const & data)
{
long long ex = 0;
auto do_bucket = [&data,&ex](int first,int last) -> double
{
double ans = 1.0;
for ( ;first != last;++first)
{
int i;
ans *= std::frexp(data[first],&i);
ex+=i;
}
return ans;
};
const int bucket_size = -std::log2( std::numeric_limits<double>::min() );
std::size_t buckets = data.size() / bucket_size;
double invN = 1.0 / data.size();
double m = 1.0;
for (std::size_t i = 0;i < buckets;++i)
m *= std::pow( do_bucket(i * bucket_size,(i+1) * bucket_size),invN );
m*= std::pow( do_bucket( buckets * bucket_size, data.size() ),invN );
return std::pow( std::numeric_limits<double>::radix,ex * invN ) * m;
}
Я думаю, что я нашел способ сделать это, это объединило две процедуры в вопросе, подобно идее Питера. Вот пример кода.
double geometric_mean(std::vector<double> const&data)
{
const double too_large = 1.e64;
const double too_small = 1.e-64;
double sum_log = 0.0;
double product = 1.0;
for(auto x:data) {
product *= x;
if(product > too_large || product < too_small) {
sum_log+= std::log(product);
product = 1;
}
}
return std::exp((sum_log + std::log(product))/data.size());
}
Плохая новость: это идет с веткой. Хорошая новость: предсказатель ветвления, вероятно, понимает это почти всегда правильно (ветвь должна срабатывать очень редко).
Ветви можно было бы избежать, используя идею Питера о постоянном количестве терминов в продукте. Проблема в том, что переполнение / недополнение может происходить в течение всего лишь нескольких терминов, в зависимости от значений.
Вы можете ускорить это путем умножения чисел, как в исходном решении, и преобразования только в логарифмы для каждого определенного числа умножений (в зависимости от размера ваших начальных чисел).
Другой подход, который дал бы лучшую точность и производительность, чем метод логарифма, состоял бы в том, чтобы компенсировать показатели вне диапазона на фиксированную величину, поддерживая точный логарифм аннулированного превышения. Вот так:
const int EXP = 64; // maximal/minimal exponent
const double BIG = pow(2, EXP); // overflow threshold
const double SMALL = pow(2, -EXP); // underflow threshold
double product = 1;
int excess = 0; // number of times BIG has been divided out of product
for(int i=0; i<n; i++)
{
product *= A[i];
while(product > BIG)
{
product *= SMALL;
excess++;
}
while(product < SMALL)
{
product *= BIG;
excess--;
}
}
double mean = pow(product, 1.0/n) * pow(BIG, double(excess)/n);
Все умножения на BIG
а также SMALL
точны, и нет звонков log
(трансцендентная и, следовательно, особенно неточная функция).
Существует простая идея уменьшить объем вычислений, а также предотвратить их переполнение. Вы можете сгруппировать числа, скажем, по крайней мере два за один раз и рассчитать их журнал, а затем оценить их сумму.
log(abcde) = 5*log(K)
log(ab) + log(cde) = 5*log(k)
Суммирование журналов для стабильного вычисления продуктов совершенно нормально и довольно эффективно (если этого недостаточно: есть способы получить векторизованные логарифмы с несколькими операциями SSE — есть также векторные операции Intel MKL).
Чтобы избежать переполнения, общепринятым методом является предварительное деление каждого числа на максимальную или минимальную запись величины (или суммирование логарифмических разностей до логарифмического максимума или логарифмического минимума). Вы также можете использовать сегменты, если числа сильно различаются (например, суммируйте журнал малых и больших чисел отдельно). Обратите внимание, что, как правило, ничего из этого не требуется, за исключением очень больших наборов, так как журнал double
никогда не бывает огромным (между -700 и 700).
Кроме того, вы должны отслеживать знаки отдельно.
вычисления log x
обычно содержит то же количество значащих цифр, что и x
кроме случаев, когда x
близко к 1
: вы хотите использовать std::log1p
если вам нужно вычислить prod(1 + x_n)
с небольшим x_n
,
Наконец, если у вас есть проблемы с ошибками округления при суммировании, вы можете использовать Суммирование Кахана или варианты.
Вместо использования логарифмов, которые очень дороги, вы можете напрямую масштабировать результаты по степеням двух.
double geometric_mean(std::vector<double> const&data) {
double huge = scalbn(1,512);
double tiny = scalbn(1,-512);
int scale = 0;
double product = 1.0;
for(auto x:data) {
if (x >= huge) {
x = scalbn(x, -512);
scale++;
} else if (x <= tiny) {
x = scalbn(x, 512);
scale--;
}
product *= x;
if (product >= huge) {
product = scalbn(product, -512);
scale++;
} else if (product <= tiny) {
product = scalbn(product, 512);
scale--;
}
}
return exp2((512.0*scale + log2(product)) / data.size());
}