Выбор алгоритма для инкрементного среднего значения с плавающей запятой (java)

Я хочу вычислить среднее значение потока двойников. Это простая задача, которая требует только хранения double и int. Я делал это, используя класс Apache commons SummaryStatistics. Однако при тестировании я заметил, что в SummaryStatistics mean были ошибки с плавающей запятой, которых не было в моей собственной реализации python. При дальнейшей проверке я обнаружил, что commons использует версию следующего алгоритма:

static double incMean(double[] data) {
    double mean = 0;
    int number = 0;
    for (double val : data) {
        ++number;
        mean += (val - mean) / number;
    }
    return mean;
}

это иногда приводит к небольшой плавающей точке ошибки, например,

System.out.println(incMean(new double[] { 10, 9, 14, 11, 8, 12, 7, 13 }));
// Prints 10.500000000000002

это также средний алгоритм, используемый утилитой guava DoubleMath.означать. Мне кажется странным, что они оба используют вышеупомянутый алгоритм вместо более наивного алгоритма:

static double cumMean(double[] data) {
    double sum = 0;
    int number = 0;
    for (double val : data) {
        ++number;
        sum += val;
    }
    return sum / number;
}

System.out.println(cumMean(new double[] { 10, 9, 14, 11, 8, 12, 7, 13 }));
// Prints 10.5

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

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

public static double accurateMean(double[] data) {
    BigDecimal sum = new BigDecimal(0);
    int num = 0;
    for (double d : data) {
        sum = sum.add(new BigDecimal(d));
        ++num;
    }
    return sum.divide(new BigDecimal(num)).doubleValue();
}

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

Random rand = new Random();
double[] data = new double[1 << 29];
for (int i = 0; i < data.length; ++i)
    data[i] = rand.nextDouble();

System.out.println(accurateMean(data)); // 0.4999884843826727
System.out.println(incMean(data));      // 0.49998848438246
System.out.println(cumMean(data));      // 0.4999884843827622

есть ли у кого-нибудь какие-либо оправдания относительно того, почему apache commons и guava выбрали первый метод вместо последнее?

Edit: ответ на мой вопрос кажется ясным, ответ заключается в том, что кнут предложил его в искусстве программирования Vol II 4.2.2 (15) (спасибо Луису Вассерману за подсказку, чтобы посмотреть на источник гуавы). Тем не менее, в книге кнут предлагает этот метод для расчета среднего для загрузки надежного расчета стандартного отклонения, не обязательно говоря, что это оптимальный средний расчет. На основе прочтения дополнительной главы я реализовал четвертую имею в виду:

static double kahanMean(double[] data) {
    double sum = 0, c = 0;
    int num = 0;
    for (double d : data) {
        ++num;
        double y = d - c;
        double t = sum + y;
        c = (t - sum) - y;
        sum = t;
    }
    return sum / num;
}

выполняя те же тесты, что и выше (несколько раз, ничего статистически значимого), я получаю тот же результат, что и реализация BigDecimal. Я могу себе представить, что обновление среднего кнута быстрее, чем использование более сложного метода суммирования, но более сложный метод кажется эмпирически более точным при оценке среднего, что я наивно ожидал бы также привести к лучшим обновлениям стандартного отклонения. Есть ли другая причина использовать метод кнута, кроме того, что он, вероятно, быстрее?

1 ответов


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

инкрементный подход к обновлению более численно стабилен при взятии среднего значения большого количества выборок. Вы можете видеть это в incMean все переменные всегда имеют порядок типичного значения данных; однако в суммированной версии переменная sum из приказа N*mean, эта разница в масштабе может вызвать проблемы из-за конечной точности математики с плавающей запятой.

в случае float(16bits) можно построить искусственные проблемные случаи: например, несколько редких образцов O(10^6) а остальные O(1) (или меньше), или вообще, если у вас есть миллионы точек данных, инкрементное обновление обеспечит более точные результаты.

эти проблемные случаи с меньшей вероятностью используют doubles (именно поэтому ваши тестовые случаи все дайте почти тот же результат), но для очень больших наборов данных с большим разбросом значений могут возникнуть те же числовые проблемы, поэтому общепринятой хорошей практикой является использование инкрементного подхода к принятию средних (и других моментов!)

преимущества способ Каган - это:

  1. существует только одна операция деления (инкрементный подход требует N подразделения),

  2. в фанки, почти круговая математика-это метод смягчения ошибок с плавающей запятой, которые возникают при суммировании грубой силы; подумайте о переменной c как "исправление" для применения к следующей итерации.

тем не менее, проще кодировать (и читать) инкрементный подход.