Числовая точность для разности квадратов

в моем коде я часто вычисляю такие вещи, как следующий фрагмент (здесь код C для простоты):

float cos_theta = /* some simple operations; no cosf call! */;
float sin_theta = sqrtf(1.0f - cos_theta * cos_theta); // Option 1

В этом примере игнорируйте тот факт, что аргумент квадратного корня может быть отрицательным из-за неточностей. Я исправил это дополнительным вызовом fdimf. Тем не менее, я задавался вопросом, является ли следующее более точным:

float sin_theta = sqrtf((1.0f + cos_theta) * (1.0f - cos_theta)); // Option 2

cos_theta находится между -1 и +1, поэтому для каждого выбора будут ситуации, когда я вычитаю одинаковые числа и, таким образом, теряю точность, верно? Что является наиболее точным и почему?


person cschwan    schedule 13.08.2013    source источник
comment
double обычно имеет большую точность, чем float.   -  person Pete Becker    schedule 13.08.2013
comment
@PeteBecker: И long double имеет еще большую точность, но float для меня быстрее!   -  person cschwan    schedule 13.08.2013
comment
Вероятно, самым точным вычислением 1.0f - cos_theta * cos_theta является fmaf(cos_theta, -cos_theta, 1.0f). Но если вас так заботит скорость, что вы не хотите использовать double для промежуточных вычислений, вам не следует использовать fmaf на процессоре, который не предоставляет ее в виде отдельной аппаратной инструкции, потому что эмулировать ее очень дорого (моя лучшая попробуй реализовать fmaf для процессора у которого его нет на ideone.com/kx7MXE , можно подтянуть немного, но это 1- использует двойники 2- будет продолжать делать много операций после затяжки)   -  person Pascal Cuoq    schedule 13.08.2013
comment
вычитание одинаковых чисел [и, таким образом] потеряет точность, неправильно   -  person    schedule 13.08.2013
comment
@PascalCuoq: Да, fmaf, вероятно, слишком дорого, но тем не менее спасибо. Мой вопрос больше направлен на то, какую точность я могу получить бесплатно.   -  person cschwan    schedule 13.08.2013
comment
@cschwan - Тогда вы не работаете на оборудовании x86; Вычисления float и double выполняются с 80-битной архитектурой x86. В большинстве случаев люди используют float для оптимизации пространства, а не скорости.   -  person Pete Becker    schedule 13.08.2013
comment
@DieterLücking - что с этим не так? Вообще говоря, вычитание двух чисел с одинаковыми значениями дает очень низкую точность, потому что в итоге вы получаете в основном биты шума.   -  person Pete Becker    schedule 13.08.2013


Ответы (6)


Наиболее точный способ с числами с плавающей запятой, вероятно, заключается в вычислении как sin, так и cos с помощью одной инструкции x87, fsincos.

Однако, если вам нужно выполнить вычисления вручную, лучше сгруппировать аргументы с одинаковыми величинами. Это означает, что второй вариант более точен, особенно когда cos_theta близко к 0, когда точность имеет наибольшее значение.

В статье Что должен знать каждый компьютерный ученый об арифметике с плавающей запятой примечания:

Выражение x2 - y2 — это еще одна формула, демонстрирующая катастрофическую отмену. Более точно вычислить его как (x - y)(x + y).

Изменить: это сложнее, чем это. Хотя приведенное выше в целом верно, (x - y)(x + y) немного менее точно, когда x и y имеют очень разные величины, как поясняется в сноске к утверждению:

В этом случае (x - y)(x + y) имеет три ошибки округления, а x2 - y2 имеет только две, так как ошибка округления допущена при вычислении меньшее из x2 и y2 не влияет на окончательное вычитание.

Другими словами, взятие x - y, x + y и произведения (x - y)(x + y) приводит к ошибкам округления (3 шага ошибки округления). x2, y2 и вычитание x2 - y2 также приводят к ошибкам округления, но ошибка округления, полученная путем возведения в квадрат относительно небольшого числа (меньшего из x и y), настолько незначительна, что фактически существует только два шага ошибки округления, что делает разницу квадратов более точной.

Так что вариант 1 на самом деле будет более точным. Это подтверждается тестом Java от dev.brutus.

person 1''    schedule 13.08.2013
comment
Вероятно, это так, но я не вызываю cosf; косинус здесь получается с помощью геометрических соотношений. - person cschwan; 13.08.2013
comment
fsincos имеет ошибочное сокращение аргументов. - person Eric Postpischil; 13.08.2013
comment
@EricPostpischil Не могли бы вы уточнить? - person 1''; 13.08.2013
comment
Хм, а можно пояснить сноску 7 в статье? [..] оно немного менее точно, чем x² - y², если x ‹‹ y или x ›› y. Справедливо ли это здесь? - person cschwan; 13.08.2013
comment
Тригонометрические инструкции Intel FPU используют 66-битное значение для π. Входные данные, величина которых превышает π/2, уменьшаются путем вычитания кратного этого значения. (Я ожидаю, что граница равна π/2, но для уверенности нужно найти конкретную документацию.) Таким образом, ошибка возникает еще до того, как фактическая тригонометрическая функция будет вычислена. 66 бит могут показаться точными, но некоторые значения, представляемые в виде чисел с плавающей запятой, оказываются близкими к кратным π. Таким образом, их сокращение очень близко к нулю. Небольшая ошибка в 66 битах может быть большой по сравнению с почти нулевым значением. Чем больше становится вход, тем хуже. - person Eric Postpischil; 13.08.2013
comment
@EricPostpischil Справедливо ли сказать, что если аргумент гарантированно будет небольшим, как это было бы для любого геометрического вычисления (конечно, между -2π и 2π и, возможно, даже в 1-м квадранте), то fsincos по-прежнему является самым точным? - person 1''; 13.08.2013
comment
Инструкции могут быть наиболее точными для 80-битного формата с плавающей запятой, но я не знаю о 64-битном. (Они не идеальны; в документации Intel указано, что ошибка находится в пределах 1 ULP на последних процессорах.) Для 64-разрядных версий CRlibm обеспечивает наилучшие возможные результаты для синуса и косинуса; нет более близких представимых значений, чем значения, возвращаемые CRlibm, и они обеспечивают формальное доказательство этого. Итак, если есть хотя бы один результат fsincos, который округляется неправильно при округлении до 64-бит, то CRlibm однозначно лучше. - person Eric Postpischil; 13.08.2013
comment
Последний пункт интересен. Рассмотрим синус или косинус, который должен быть очень близок к середине между двумя 64-битными значениями с плавающей запятой. Пусть u — математически точное значение. Пусть v равно u, правильно округленному до 64-битного значения. Доказано, что CRlibm возвращает v. Предположим, что fsincos возвращает w, а w очень близко к u, но едва находится на другой стороне промежуточной точки между v и его сосед. Затем при округлении до 64-бит w создает соседа, а не v. Таким образом, хотя результат fsincos более точен, чем CRlibm, округление до 64-битного менее точно. - person Eric Postpischil; 13.08.2013
comment
(Вышеупомянутое является теоретической возможностью. Это реалистично; в мантиссе 80-битного числа с плавающей запятой всего на одиннадцать бит больше, чем в 64-битном числе с плавающей запятой, поэтому примерно один из 2048 случаев может лежать около средней точки, а затем вам нужна небольшая ошибка в fsincos, чтобы протолкнуть ее. Но я не могу определить одну из них навскидку. В документации CRlibm могут быть некоторые подсказки.) - person Eric Postpischil; 13.08.2013

Я написал небольшой тест. Он вычисляет ожидаемое значение с двойной точностью. Затем он вычисляет ошибку с вашими параметрами. Первый вариант лучше:

Algorithm: FloatTest$1
option 1 error = 3.802792362162126
option 2 error = 4.333273185303996
Algorithm: FloatTest$2
option 1 error = 3.802792362167937
option 2 error = 4.333273185305868

Java-код:

import org.junit.Test;

public class FloatTest {

    @Test
    public void test() {
        testImpl(new ExpectedAlgorithm() {
            public double te(double cos_theta) {
                return Math.sqrt(1.0f - cos_theta * cos_theta);
            }
        });
        testImpl(new ExpectedAlgorithm() {
            public double te(double cos_theta) {
                return Math.sqrt((1.0f + cos_theta) * (1.0f - cos_theta));
            }
        });
    }

    public void testImpl(ExpectedAlgorithm ea) {
        double delta1 = 0;
        double delta2 = 0;
        for (double cos_theta = -1; cos_theta <= 1; cos_theta += 1e-8) {
            double[] delta = delta(cos_theta, ea);
            delta1 += delta[0];
            delta2 += delta[1];
        }

        System.out.println("Algorithm: " + ea.getClass().getName());
        System.out.println("option 1 error = " + delta1);
        System.out.println("option 2 error = " + delta2);
    }

    private double[] delta(double cos_theta, ExpectedAlgorithm ea) {
        double expected = ea.te(cos_theta);
        double delta1 = Math.abs(expected - t1((float) cos_theta));
        double delta2 = Math.abs(expected - t2((float) cos_theta));

        return new double[]{delta1, delta2};
    }

    private double t1(float cos_theta) {
        return Math.sqrt(1.0f - cos_theta * cos_theta);
    }

    private double t2(float cos_theta) {
        return Math.sqrt((1.0f + cos_theta) * (1.0f - cos_theta));
    }

    interface ExpectedAlgorithm {
        double te(double cos_theta);
    }

}
person dev.brutus    schedule 13.08.2013
comment
Хороший! Но я не понимаю разницы между FloatTest$1 и FloatTest$2. Почему у вас 4 разных номера (вместо двух)? Прошло некоторое время с тех пор, как я сам написал Java. - person cschwan; 13.08.2013
comment
Я использую те же параметры для расчета ожидаемого значения :) Итак, FloatTest$1 тестирует с алгоритмом варианта 1. И тесты FloatTest$2 с алгоритмом варианта 2. - person dev.brutus; 13.08.2013
comment
А, ожидаемое значение тоже должно быть вычислено, правильно — привет комбинаторике ;) ! - person cschwan; 13.08.2013
comment
Было бы неплохо иметь график ошибки в зависимости от cos_theta. - person cschwan; 13.08.2013
comment
Является ли это добавлением общей абсолютной ошибки, выбранной для домена? Почему абсолютная ошибка является подходящим показателем, а не относительная ошибка или какой-либо другой показатель? - person Eric Postpischil; 13.08.2013
comment
Эрик, я думаю, что в данном случае нет большой разницы между абсолютным или относительным показателем. Расчетная стоимость одинакова для обоих вариантов. Так что этот показатель пропорционален. Абсолютная метрика проста для кода. - person dev.brutus; 14.08.2013
comment
Я думаю, что есть разница. Относительная ошибка будет придавать больший вес тому, когда результат мал (cos_theta близок к +1 или -1), когда я ожидаю проблем для метода 1. - person Jitse Niesen; 14.08.2013

Правильный способ рассуждать о числовой точности некоторого выражения состоит в следующем:

  1. Измерьте несоответствие результата относительно правильного значения в ULP (единица на последнем месте), представленная в 1960 г. У. Х. Кахан. Вы можете найти реализации C, Python и Mathematica здесь и узнать больше о здесь.
  2. Различайте два или более выражений на основе наихудшего случая, который они производят, а не по средней абсолютной ошибке, как это делается в других ответах, или по какой-либо другой произвольной метрике. Вот как строятся полиномы численной аппроксимации (алгоритм Ремеза), как анализируются реализации стандартных библиотечных методов (например, Intel atan2) , так далее...

Имея это в виду, version_1: sqrt(1 - x * x) и version_2: sqrt((1 - x) * (1 + x)) дают существенно разные результаты. Как показано на графике ниже, версия_1 демонстрирует катастрофическую производительность для x, близкого к 1, с ошибкой > 1_000_000 ulps, в то время как, с другой стороны, ошибка версии_2 ведет себя хорошо.

Вот почему я всегда рекомендую использовать версию_2, то есть использовать формулу квадратной разницы.

введите здесь описание изображения

Код Python 3.6, создающий файл square_diff_error.csv:

from fractions import Fraction
from math import exp, fabs, sqrt
from random import random
from struct import pack, unpack


def ulp(x):
    """
    Computing ULP of input double precision number x exploiting
    lexicographic ordering property of positive IEEE-754 numbers.

    The implementation correctly handles the special cases:
      - ulp(NaN) = NaN
      - ulp(-Inf) = Inf
      - ulp(Inf) = Inf

    Author: Hrvoje Abraham
    Date: 11.12.2015
    Revisions: 15.08.2017
               26.11.2017
    MIT License https://opensource.org/licenses/MIT

    :param x: (float) float ULP will be calculated for
    :returns: (float) the input float number ULP value
    """

    # setting sign bit to 0, e.g. -0.0 becomes 0.0
    t = abs(x)

    # converting IEEE-754 64-bit format bit content to unsigned integer
    ll = unpack('Q', pack('d', t))[0]

    # computing first smaller integer, bigger in a case of ll=0 (t=0.0)
    near_ll = abs(ll - 1)

    # converting back to float, its value will be float nearest to t
    near_t = unpack('d', pack('Q', near_ll))[0]

    # abs takes care of case t=0.0
    return abs(t - near_t)


with open('e:/square_diff_error.csv', 'w') as f:
    for _ in range(100_000):
        # nonlinear distribution of x in [0, 1] to produce more cases close to 1
        k = 10
        x = (exp(k) - exp(k * random())) / (exp(k) - 1)

        fx = Fraction(x)
        correct = sqrt(float(Fraction(1) - fx * fx))

        version1 = sqrt(1.0 - x * x)
        version2 = sqrt((1.0 - x) * (1.0 + x))

        err1 = fabs(version1 - correct) / ulp(correct)
        err2 = fabs(version2 - correct) / ulp(correct)

        f.write(f'{x},{err1},{err2}\n')

Код Mathematica, который создает окончательный график:

data = Import["e:/square_diff_error.csv"];

err1 = {1 - #[[1]], #[[2]]} & /@ data;
err2 = {1 - #[[1]], #[[3]]} & /@ data;

ListLogLogPlot[{err1, err2}, PlotRange -> All, Axes -> False, Frame -> True,
    FrameLabel -> {"1-x", "error [ULPs]"}, LabelStyle -> {FontSize -> 20}]
person ahrvoje    schedule 24.11.2017
comment
Если я вас правильно понял, вы говорите, что метод 1 лучше, когда x мало. Что произойдет, если x окажется рядом с 1? В чем я не уверен, так это в том, согласен ли я с вашим методом измерения ошибки. Я думаю, вы предполагаете, что точность распределена линейно, так как вы делите на ulp(correct). Не лучше ли было бы измерить количество следующих представимых чисел с плавающей запятой между правильным и приблизительным результатом? - person cschwan; 07.12.2017
comment
Просто чтобы уточнить мой комментарий выше, в C я бы использовал наименьшее число (correct и approximated) и проверил, как часто мне нужно вызывать nextafter, пока я не получу число, которое больше, чем наибольшее из (exact, approximated). Что было бы не так с таким подходом? - person cschwan; 07.12.2017
comment
Метод 1 хорош, когда x мал. Для x, близкого к 1, большая точность «округляется» при вычислении x * x, поэтому вы теряете важные биты и не можете получить точное 1-x * x. В методе 2 выражение делится на малую и большую части, которые вычисляются точно, а большая часть 1+x просто играет роль поправки на меньшую часть 1-x. Поскольку 1+x‹2, вы никогда не столкнетесь с большой проблемой точности, кумулятивная ошибка всегда должна быть около 1 мкл или около того. Эту часть нелегко объяснить в краткой текстовой форме, но ее можно проверить численным экспериментом, а при самом строгом подходе — математикой. теорема. - person ahrvoje; 08.12.2017
comment
Вы правы в том, что использование количества представлений с плавающей запятой между правильным и неправильным значением является одним из способов измерения. Тем не менее, я всегда стремлюсь реализовать подход Кахана, который заключается в последовательном использовании единственной меры ошибки, которая дает максимально возможную ошибку, и обычно это деление с помощью ulp(correct). Эти два подхода являются инъективными, поэтому при сравнении вы всегда будете приходить к одному и тому же наихудшему выводу. - person ahrvoje; 08.12.2017
comment
Кроме того, в случае анализа очень точных методов, для которых ошибка даже не превышает 1ulp, не имеет большого смысла подсчитывать количество представимых значений, так как между ними нет промежуточных значений, и, разделив на ulp(correct), вы может получить дробную ошибку, такую ​​как 0,2ulp, 0,7ulp... Для некоторых функций важно содержать ошибку в пределах [0, 0,5] ulp. - person ahrvoje; 08.12.2017
comment
Спасибо за разъяснение - я не думал о случае, когда можно заинтересоваться дробными ошибками. Но я все еще не понимаю, почему вы делите на ulp (правильно). Разве вы не разделите на min(ulp(correct), ulp(approximated)), чтобы получить максимальную ошибку? - person cschwan; 08.12.2017
comment
Это вопрос вычисления ошибки в целом, а не только для нужд с плавающей запятой, вы всегда сравниваете ее с некоторой характеристикой правильного результата, а не неправильного. Не существует теоремы, доказывающей, что деление с помощью ulp(correct) является лучшим способом сделать это, но этот подход пережил все случаи использования и крайние случаи за последние 50 с лишним лет, и их много... - person ahrvoje; 10.12.2017

Кроме того, у вас всегда будут проблемы, когда тета мала, потому что косинус плоский вокруг тета = 0. Если тета находится в диапазоне от -0,0001 до 0,0001, тогда cos(theta) в float ровно единице, поэтому ваш sin_theta будет точно равен нуль.

Чтобы ответить на ваш вопрос, когда cos_theta близок к единице (соответствует малому тета), ваше второе вычисление явно более точное. Это показано в следующей программе, в которой перечислены абсолютные и относительные ошибки для обоих вычислений для различных значений cos_theta. Ошибки вычисляются путем сравнения со значением, которое вычисляется с точностью до 200 бит с использованием библиотеки GNU MP, а затем преобразуется в число с плавающей запятой.

#include <math.h>
#include <stdio.h>
#include <gmp.h>

int main() 
{
  int i;
  printf("cos_theta       abs (1)    rel (1)       abs (2)    rel (2)\n\n");
  for (i = -14; i < 0; ++i) {
    float x = 1 - pow(10, i/2.0);
    float approx1 = sqrt(1 - x * x);
    float approx2 = sqrt((1 - x) * (1 + x));

    /* Use GNU MultiPrecision Library to get 'exact' answer */
    mpf_t tmp1, tmp2;
    mpf_init2(tmp1, 200);  /* use 200 bits precision */
    mpf_init2(tmp2, 200);
    mpf_set_d(tmp1, x);
    mpf_mul(tmp2, tmp1, tmp1);  /* tmp2 = x * x */
    mpf_neg(tmp1, tmp2);        /* tmp1 = -x * x */
    mpf_add_ui(tmp2, tmp1, 1);  /* tmp2 = 1 - x * x */
    mpf_sqrt(tmp1, tmp2);       /* tmp1 = sqrt(1 - x * x) */
    float exact = mpf_get_d(tmp1);

    printf("%.8f     %.3e  %.3e     %.3e  %.3e\n", x,
           fabs(approx1 - exact), fabs((approx1 - exact) / exact),
           fabs(approx2 - exact), fabs((approx2 - exact) / exact));
    /* printf("%.10f  %.8f  %.8f  %.8f\n", x, exact, approx1, approx2); */
  }
  return 0;
}

Выход:

cos_theta       abs (1)    rel (1)       abs (2)    rel (2)

0.99999988     2.910e-11  5.960e-08     0.000e+00  0.000e+00
0.99999970     5.821e-11  7.539e-08     0.000e+00  0.000e+00
0.99999899     3.492e-10  2.453e-07     1.164e-10  8.178e-08
0.99999684     2.095e-09  8.337e-07     0.000e+00  0.000e+00
0.99998999     1.118e-08  2.497e-06     0.000e+00  0.000e+00
0.99996835     6.240e-08  7.843e-06     9.313e-10  1.171e-07
0.99989998     3.530e-07  2.496e-05     0.000e+00  0.000e+00
0.99968380     3.818e-07  1.519e-05     0.000e+00  0.000e+00
0.99900001     1.490e-07  3.333e-06     0.000e+00  0.000e+00
0.99683774     8.941e-08  1.125e-06     7.451e-09  9.376e-08
0.99000001     5.960e-08  4.225e-07     0.000e+00  0.000e+00
0.96837723     1.490e-08  5.973e-08     0.000e+00  0.000e+00
0.89999998     2.980e-08  6.837e-08     0.000e+00  0.000e+00
0.68377221     5.960e-08  8.168e-08     5.960e-08  8.168e-08

Когда cos_theta не близок к единице, точность обоих методов очень близка друг к другу и к ошибке округления.

person Jitse Niesen    schedule 14.08.2013

[Отредактировано для серьезных размышлений] Мне кажется, что вариант 2 будет лучше, потому что для числа, такого как 0.000001, например, вариант 1 вернет синус как 1, а вариант вернет число чуть меньше 1.

person Mark B    schedule 13.08.2013

Нет разницы в моем варианте, поскольку (1-x) сохраняет точность, не влияя на переносимый бит. Тогда для (1+x) то же верно. Тогда единственное, что влияет на точность бита переноса, — это умножение. Таким образом, в обоих случаях есть одно единственное умножение, поэтому они оба с одинаковой вероятностью дадут одну и ту же ошибку бита переноса.

person Eamonn Kenny    schedule 19.07.2016
comment
Удивительно, что ваш вопрос касается точности, а первые 10 ответов или около того касаются скорости. Почему люди не отвечают на заданный вами вопрос? - person Eamonn Kenny; 19.07.2016