Сообщений 30    Оценка 16        Оценить  
Система Orphus

Проблема толерантности к погрешностям операций с плавающей запятой

Автор: Бахтин Николай Иванович
Источник: RSDN Magazine #3-2010
Опубликовано: 08.07.2011
Исправлено: 10.12.2016
Версия текста: 1.1
Введение
Пример 1 – Условный переход
Пример 2 – Проверка на равенство
Пример 3 – Проблемные функции
Пример 4 – Решение проблемы неопределенности через рекурсию.
Заключение

Введение

Поясним, что означает выражение «толерантность к погрешностям вычислений». Когда процессор выполняет операции над числами с плавающей запятой, то всегда возникает некоторая погрешность вычислений. Во многих случаях наличие этой погрешности не оказывает никакого существенного влияния на результат работы программы. Однако возможны ситуации, когда наличие погрешности вычислений может создавать серьезные проблемы, вплоть до неправильных результатов расчета или сбоев в работе программы.

Рассмотрим небольшой пример. Пускай программа делает расчет по формуле:

      double S = sqrt(a – b*c);

Будем считать, что по естественному ограничению решаемой задачи переменная a никогда не будет меньше величины b*c. Однако, возможна ситуация, когда a равна b*c, тогда S должна быть равна нулю. Но операция умножения на процессоре неизбежно будет выполнена с погрешностью, и если в силу погрешности результат перемножения b*c станет немного больше, то sqrt возвратит INF вместо ожидаемого нуля. Если это не было учтено алгоритмом, то дальнейшее поведение программы будет непредсказуемым.

Обычно такие ситуации возникают в «краевых» случаях, когда некоторые расчетные величины подходят к своему естественному пределу, выходить за который эта величина не должна.

Это пример противоречия между математической абстракцией и реальностью. Алгоритм, рассмотренный с позиции математика, не имеет никаких изъянов. Тогда как программная реализация таит в себе проблемы. Бывают даже случаи, когда красивые математические идеи оказываются совершенно непригодными для программной реализации.

Так как подобные ситуации нарушения работы алгоритмов не редкость, то необходимо разрабатывать эти алгоритмы таким образом, чтобы они были толерантны к погрешностям вычислений, то есть не создавали сбоев работы программы и не выдавали неверные результаты.

Коварство этой ситуации заключается в том, что ошибка алгоритма может долгое время никак себя не проявлять. Как уже было сказано, проблемы возникают в особых, «краевых» случаях. Несмотря на то, что опытные программисты стараются проверить в первую очередь именно «краевые» случаи, их не всегда легко выявить. Любому программисту, имеющему дело с разработкой программ, осуществляющих сложные математические расчеты, полезно обладать способностью находить проблемные места в коде.

Пример 1 – Условный переход

Рассмотрим следующий пример:

      double S = SomeCalc();
if(S>1.0)
{
  Way1(S);
}
else
{
  Way2(S);
}

На первый взгляд все достаточно просто. Расчет алгоритма может пойти по одному из двух путей в зависимости от того, какое значение примет величина S. Трудности начинаются, когда S принимает значения близкие к 1.0. В результате погрешности вычислений при S>1.0 программа может перейти к выполнению функции Way2(), хотя должна выполнить Way1(). Если такая ситуация произойдет, то программа начнет выполнять совершенно не те действия, которые ожидаются.

Каждый условный переход – потенциальная проблема. Поэтому при разработке алгоритма желательно уменьшить число условных переходов, зависящих от вычислений, сделанных с погрешностью.

Пример 2 – Проверка на равенство

      double a = GetA();
double b = GetB();
if(a==b)
{
  Func1();
}

Частный случай предыдущего примера. Встречается очень часто. При работе с вещественными числами никогда нельзя сравнивать их напрямую, как это сделано в примере. Вместо этого лучше проверять – не превышает ли разница между a и b некоторой очень малой величины Epsilon.

      double a = GetA();
double b = GetB();
if(fabs(a-b)<Epsilon)
{
  Func1();
}

Величину Epsilon, скорее всего, придется подобрать вручную исходя из ожидаемых масштабов величин a и b.

Пример 3 – Проблемные функции

Если в математических расчетах встречаются такие операции как деление, извлечение квадратного корня, взятие арксинуса и другие, то возможна ситуация, когда эти операции будут осуществляться при недопустимых значениях. Которые становятся таковыми в результате погрешности вычислений.

      double a = b / c;

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

Пример 4 – Решение проблемы неопределенности через рекурсию.

Рассмотрим более сложный случай «из жизни» на примере задачи, которая является фрагментом программы моделирования разреженного газа. Пускай имеется некая область, разбитая на треугольники, в которой находится объект (молекула), представленный материальной точкой (рис.1). Данный объект движется по прямой до момента столкновения со стенкой замкнутой области. Стоит задача рассчитать движение этой молекулы по треугольной сетке.


Рисунок 1. Пример движения молекулы в треугольной сетке.

Молекула будет перемещаться по сетке из одного треугольника в другой, пересекая их грани. Необходимо определить точку пересечения траектории молекулы с гранью, чтобы знать в каком месте и через какую грань будет осуществлен переход в новый треугольник. Данный расчет таит в себе проблемы, связанные с погрешностями вычислений. «Краевым» случаем будет вариант, когда траектория движения пересечет вершину треугольника. В отличие от случая движения через грань, в такой ситуации не всегда можно дать однозначный ответ – в какой новый треугольник попадет материальная точка после пересечения. На итоговый выбор может оказывать существенное влияние величина погрешности сделанных вычислений.

В алгоритме необходимо сделать выбор – через какую из сторон треугольника пройдет траектория движения молекулы и соответственно, в какой новый треугольник попадет молекула. Если траектория проходит где-то посередине стороны треугольника, то проблем никаких нет. Если траектория проходит через вершину треугольника, то возникает неясная ситуация. Необходимо сделать выбор, в какой новый треугольник попадет молекула. Можно просто выбрать какую-то одну из сторон произвольным образом, но такой алгоритм будет ошибочным. Молекула, пройдя небольшой путь, может оказаться в совершенно других треугольниках, чем мы предполагали.

По сути, возникает ситуация, когда вместо двух вариантов: либо вариант А, либо вариант Б; мы вынуждены иметь дело с тремя вариантами – А, Б и неопределенный вариант. Для того чтобы разработать алгоритм, который будет корректно обрабатывать неопределенные варианты можно через рекурсию рассмотреть различные пути развития событий. То есть вместо выбора из двух вариантов мы рассмотрим сразу оба и отбросим лишний лишь тогда, когда это будет окончательно ясно.

Возьмем наиболее неблагоприятный случай:


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

На рисунке 2 материальная точка перемещается из треугольника 1 в треугольник 10, при этом остается некоторая двойственность промежуточного пути. Из-за того, что прямая линия траектории проходит строго по граням, которые являются общими для двух треугольников, 4 и 5, а также 8 и 9 неясно из какого в какой треугольник надо осуществлять переход. Возможны различные варианты переходов из одного треугольника в другой. Например, 1,2,4,7,8,10 или 1,3,5,6,9,11,10. Видно, что это достаточно тяжелая ситуация для многих алгоритмов, рассчитывающих траекторию движения по треугольной сетке.

Использование рекурсии позволяет достаточно легко обойти все трудности, связанные с совершением выбора в условиях неопределенности. В худшем случае мы просто переберем все возможные варианты.

Рассмотрим реализацию данной идеи на практике. Необходимо определить траекторию движения точки в виде последовательности треугольников, в которые попадает данная точка в процессе движения. Функция Process() осуществляет расчет этой траектории. В момент попадания в треугольник tri точка имеет вектор координат r и скорость v. Надо определить дальнейшую траекторию движения за время DT. Результат расчета занесем в объект result. Process() будет повторно вызывать сам себя в ходе расчета для каждого нового треугольника сетки, куда попадет точка. В каждом таком вызове будет досчитываться оставшийся путь. При этом функции передается аргумент old_e, содержащий указатель на грань, через которую точка попала в треугольник tri в ходе своего движения.

      void Process(Result* result,MTriangle* tri,MEdge* old_e,Point& r,Point& v,double DT)
{
//Нас не интересует грань, через которую мы попали в данный треугольник.//По этой причине мы считаем, что дальнейший путь точки может идти//лишь через оставшиеся две грани. Рассматриваются оба варианта.//Один из этих вариантов будет скорее всего отвергнут в функции//ProcessEdge(). if(tri->e1!=old_e) 
  {
    ProcessEdge(result,tri->e1,tri,r,v,DT);
  }
  if(tri->e2!=old_e)
  {
    ProcessEdge(result,tri->e2,tri,r,v,DT);
  }
  if(tri->e3!=old_e)
  {
    ProcessEdge(result,tri->e3,tri,r,v,DT);
  }
}

В функции ProcessEdge() рассчитывается перемещение точки так, как будто она покинет треугольник через грань e. Определяется точка пересечения этой грани с траекторией движения. Здесь так же определяется сам факт того, может ли точка покинуть треугольник через эту грань. Возможны три варианта:

  1. Точка однозначно покидает треугольник через эту грань.
  2. Точка однозначно покидает треугольник не через эту грань.
  3. Неопределенная ситуация, обусловленная наличием погрешности вычислений или особыми случаями движения. Примером такого особого случая может служить перемещение строго через одну из вершин треугольника.

Расчет продолжается в случае первого и третьего варианта, и прекращается в случае второго. Если точка в ходе своего движения столкнется со стенкой, то она отскакивает от этой стенки по закону зеркального отражения.

      void ProcessEdge(Result* result,MEdge* e,MTriangle* tri,Point& r,Point& v,double DT)
{
  if(result->isValid)  //Если решение уже нашли, то прекратить расчетreturn;

//Если точка не находится в данном треугольнике, то расчет по этому направлению прекращаетсяif(!ValidateTri(tri,r)) 
    return;

  double S = e->n * v;
  bool isNullS = abs(S)<MEpsilon;
  if(isNullS)
  {
    return; //Точка движется параллельно грани
  }
  else
  {
    if(S<0.0)
    {
      //Определяем момент времени пересечения грани
      Point n = e->n;
      Point p = e->p1->ToPoint();
      double W = (n*p) - (n*r);
      double t = W/S;

              if(t>DT) //конец пути
      {
        Point new_r = r + v*DT;
        if(!ValidateTri(tri,new_r))
        {
          return;
        }

        result->isValid = true;
        result->tri = tri;
        result->r = new_r;
        result->v = v;
      }
      else
      {
        MEdge* twin_e = e->twin;
        bool isWall = (twin_e==NULL) ? true: (twin_e->own_tri==NULL?true:false);
        if(isWall) //Является ли данная грань стенкой?
        {
          Point new_r = r + v * t;
          Point new_v = v + ((-2.0*(v*n))*n);
          Process(result,tri,e,new_r,new_v,DT-t);
        }
        else
        {
          Point new_r = r + v * t;
          Process(result,twin_e->own_tri,twin_e,new_r,v,DT-t);
        }
      }
    }
  }
}

bool ValidateEdge(MEdge* e,Point& r)
{
  double S = (e->n)*(r - e->p1->ToPoint());
  if(abs(S)<MEpsilon)
    returntrue;
  elseif(S>0.0)
    returntrue;
  elsereturnfalse;
}
bool ValidateTri(MTriangle* tri,Point& r)
{
  if(!ValidateEdge(tri->e1,r))
    returnfalse;
  if(!ValidateEdge(tri->e2,r))
    returnfalse;
  if(!ValidateEdge(tri->e3,r))
    returnfalse;
  returntrue;
}

Заключение

Наличие погрешности вычислений нередко приводит к тому, что математически красивый алгоритм расчета может быть испорчен суровой реальностью. Погрешность в краевых случаях порождает определенную «нечеткость» вычислительного процесса. Хорошо если эта «нечеткость» ни на что не влияет, но есть немало случаев, когда это все же создает серьезные проблемы. Хуже, когда программист находится в блаженном неведении о потенциальных проблемах.

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


Эта статья опубликована в журнале RSDN Magazine #3-2010. Информацию о журнале можно найти здесь
    Сообщений 30    Оценка 16        Оценить