2.3 Накопления ошибок при многократных операциях с плавающей точкой

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

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

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

package main

import "fmt"

func main() {
	var sum float32
	for i := 0; i < 100_000; i++ {
		sum += 0.1
	}

	fmt.Println(sum) // 9998.557
}

Так как 0.1 * 100 000 = 10 000, то логично ожидать такого же поведения и в коде, но в результате будет выведено 9998.557.

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

Иными словами: 0.110 = 0.00011001100110011…2

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

  • Код
  • Вывод
package main

import "fmt"

func main() {
	var sum float32
	const steps = 100_000 // уменьшаем для наглядности
	const add float32 = 0.1

	for i := 1; i <= steps; i++ {
		sum += add
		if i%10_000 == 0 { // выводим каждые 10_000 шагов
			fmt.Printf("Шаг %d: sum = %.10f, ожидание = %.10f, ошибка = %.10f\n",
				i, sum, float32(i)*add, sum-float32(i)*add)
		}
	}
}
Шаг 10000: sum = 999.9028930664, ожидание = 1000.0000000000, ошибка = -0.0971069336
Шаг 20000: sum = 1999.6588134766, ожидание = 2000.0000000000, ошибка = -0.3411865234
Шаг 30000: sum = 3000.5764160156, ожидание = 3000.0000000000, ошибка = 0.5764160156
Шаг 40000: sum = 4001.5529785156, ожидание = 4000.0000000000, ошибка = 1.5529785156
Шаг 50000: sum = 5002.5292968750, ожидание = 5000.0000000000, ошибка = 2.5292968750
Шаг 60000: sum = 6003.5058593750, ожидание = 6000.0000000000, ошибка = 3.5058593750
Шаг 70000: sum = 7004.4824218750, ожидание = 7000.0000000000, ошибка = 4.4824218750
Шаг 80000: sum = 8005.4589843750, ожидание = 8000.0000000000, ошибка = 5.4589843750
Шаг 90000: sum = 9002.4628906250, ожидание = 9000.0000000000, ошибка = 2.4628906250
Шаг 100000: sum = 9998.5566406250, ожидание = 10000.0000000000, ошибка = -1.4433593750

Почему ошибка сначала отрицательная, потом положительная?

Присходит это по двум причинам:

  1. Накопление погрешности при каждом сложении. Каждое добавление дробного числа в float32 или float64 даёт небольшую ошибку из-за ограниченной точности. Чем больше операция, тем больше отклонений и погрешностей.
  2. Рост шага округления с увеличением числа.
    Так как мантиса в числах с плавающей точкой ограничена, то при увеличений значений растет и абсолютная погрешность округления. Иными словами, чем больше число, тем больше возможная ошибка на каждом сложении, что может менять знак накопленной ошибки (сначала отрицательная, потом положительная).

Что делать на практике?

Использовать более точный тип данных

Преимущества:

  • Не требует сложного изменения кода, достаточно поменять float32 -> float64

Недостатки:

  • Погрешность не исчезает, а просто становится меньше

Когда использовать:

  • Небольшое накопление ошибки не критично для бизнес-логки
  • Требуется более высокая точность, но при этом мы готовы к тому, что небольшая погрешность будет

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

ТипПогрешность на 100_000 сложенийПрактическая точность
float32~1.4435083866заметно отличается от идеала, обратите внимание, что для 80000 итераций погрешность будет более 5
float64~0.0000000188практически точное значение

Использование целочисленные типы для арифметики

Преимущества:

  • Накопление ошибок отсутствует

Недостатки:

  • Необходимо самостоятельно определять сколько точек после запятой мы хотим поддерживать
package main

import "fmt"

func main() {
	var sum int64
	for i := 0; i < 100_000; i++ {
		sum += 1 // 1 = 0.1 * 10
	}

	fmt.Printf("%.54f", float64(sum)/10) // 10000.000000000000000000000000000000000000000000000000000000
}

Корректировать и округлять результаты

Преимущества:

  • Простой способ
  • Округление ограничивает суммарную ошибку

Недостатки:

  • Необходимо самостоятельно определять сколько точек после запятой мы хотим поддерживать
  • Подход не исправляет, а маскирует проблему
  • Добавляет дополнительные расходы на вычисления

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

package main

import (
	"fmt"
)

func round(f, p float32) float32 {
	scaled := f * p
	if scaled >= 0 {
		scaled = float32(int(scaled + 0.5))
	} else {
		scaled = float32(int(scaled - 0.5))
	}
	return scaled / p
}

func main() {
	var simpleSum float32
	var roundedSum float32

	for i := 0; i < 100_123; i++ {
		simpleSum += 0.01
		roundedSum = round(roundedSum+0.01, 100) // 100 = 10^2, поэтому округление будет до 2-х знаков
	}

	fmt.Println(simpleSum)  // 1001.89624
	fmt.Println(roundedSum) // 1001.23
	
	fmt.Printf("%.10f \n", simpleSum) // 1001.8962402344
	fmt.Printf("%.10f", roundedSum)   // 1001.2299804688
}

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

Округление не превращает число в «идеальную десятичную дробь»  — оно лишь:

  • останавливает рост накопления погрешности и уменьшает размер ошибки
  • ограничивает погрешность до заданной точности

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

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

Когда этот метод применять не нужно?

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

Использовать пакет “math/big” с big.Float и big.Rat

Преимущества:

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

Недостатки:

  • Очень медлено и ресурсоёмко, по сравнению с float64
  • big.Float хранит числа в двоичной системе, таким образом дроби без точного представления (например 0.1) могут быть не точны
  • Для работы с big.Float и big.Rat нужно понимать особенности арифметики с числами с плавающей точкой и рациональными дробями.

Так как работа с библиотекой “math/big” требует осознанного подхода, мы опишем работу с ней в отдельном разделе.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *