perl: нахождение среднего значения и дисперсии больших чисел без переполнения

Я использую подпрограмму (stats) для вычисления статистики для списка чисел. Эти числа могут быть достаточно большими, чтобы потерять точность, если они хранятся как обычные числа perl. Я получаю такие числа как строки в формате JSON. Чтобы декодировать эти строки без потери точности, я использую объект JSON::PP с allow_nonref и allow_bignum. активирован. Я отправляю список таких декодированных чисел в подпрограмму stats (см. код, показанный ниже). Эта процедура вычисляет некоторую статистику. Затем эта статистика кодируется в JSON и сохраняется в файл.

В большинстве случаев кажется, что процесс работает правильно, но для некоторых входных данных (примеры см. в коде) вычисленное значение статистики среднего и дисперсии либо явно неверно, либо кодировщик кодирует их как строки JSON, либо и то, и другое. Я подозреваю, что это связано с взаимодействием объектов Math::BigInt и Math::BigFloat, созданных декодированием JSON, и List::Util::sum0.

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

Скрипт (stats.pl) для демонстрации проблемы:

use strict;
use warnings;

use Data::Dumper;
$Data::Dumper::Varname = "DUMPED_RAWDATA";
use JSON::PP;
use List::Util;

my $JSON = JSON::PP->new->allow_bignum->utf8->pretty->canonical;

sub stats {

    #TODO fix bug about negative variance. AVOID OVERFLOW
    #TODO use GMP, XS?
 
    # @_ has decoded numbers (called RAWDATA here)
    my $n    = scalar @_;
    my $sum  = List::Util::sum0(@_);
    my $mean = $sum / $n;
    my $var  = List::Util::sum0( map { $_**2 } @_ ) / $n - $mean**2;

    my $s = {
        n        => $n,
        sum      => $sum,
        max      => List::Util::max(@_),
        min      => List::Util::min(@_),
        mean     => $mean,
        variance => $var
    };
    # DUMP STATE IF SOME ERROR OCCURS
    print Dumper( \@_ ),
      $JSON->encode( { json_encoded_stats => $s, json_encoded_rawdata => \@_ } )
      if ( '"' eq substr( $JSON->encode($var), 0, 1 ) #MEAN ENCODED AS STRING
        or '"' eq substr( $JSON->encode($mean), 0, 1 ) #VARIANCE ENCODED AS STRING
        or $var < 0 ); #VARIANCE IS NEGATIVE!
    $s;
}

my @test = (
    [
        qw( 919300112739897344 919305709216464896 919305709216464896 985592115567603712 959299136196456448)
    ],
    [qw(479655558 429035600 3281034608 3281034608 2606592908 3490045576)],
    [ qw(914426431563644928) x 3142 ]
);
for (@test) {
    print "---\n";
    stats( map { $JSON->decode($_) } @$_ );
}

Ниже приведен сокращенный вывод perl stats.pl с проблемами, обозначенными как <---.

---
$DUMPED_RAWDATA1 = [
                     '919300112739897344',
                     '919305709216464896',
                     '919305709216464896',
                     '985592115567603712',
                     '959299136196456448'
                   ];
{
   "json_encoded_rawdata" : [
      919300112739897344,
      919305709216464896,
      919305709216464896,
      985592115567603712,
      959299136196456448
   ],
   "json_encoded_stats" : {
      "max" : 985592115567603712,
      "mean" : "9.40560556587377e+17", <--- ENCODED AS STRING
      "min" : 919300112739897344,
      "n" : 5,
      "sum" : 4702802782936887296,
      "variance" : 7.46903843214008e+32
   }
}
---
$DUMPED_RAWDATA1 = [
                     479655558,
                     429035600,
                     3281034608,
                     3281034608,
                     2606592908,
                     3490045576
                   ];
{
   "json_encoded_rawdata" : [
      479655558,
      429035600,
      3281034608,
      3281034608,
      2606592908,
      3490045576
   ],
   "json_encoded_stats" : {
      "max" : 3490045576,
      "mean" : 2261233143,
      "min" : 429035600,
      "n" : 6,
      "sum" : 13567398858,
      "variance" : "-1.36775568782523e+18" <--- NEGATIVE VARIANCE, STRING ENCODED
   }
}
---
$DUMPED_RAWDATA1 = [
                     '914426431563644928',
             .
             .
             .
             <snip 3140 identical lines>
                     '914426431563644928'
                   ];
{
   "json_encoded_rawdata" : [
      914426431563644928,
      .
      .
      .
      <snip 3140 identical lines>
      914426431563644928
   ],
   "json_encoded_stats" : {
      "max" : 914426431563644928,
      "mean" : "9.14426431563676e+17", <--- STRING ENCODED
      "min" : 914426431563644928,
      "n" : 3142,
      "sum" : 2.87312784797307e+21,
      "variance" : -9.75463826617761e+22 <--- NEGATIVE VARIANCE
   }
}

person pii_ke    schedule 06.07.2020    source источник
comment
Не знаю, что делать с числами, закодированными как строки, но в любом случае, что касается вычисления дисперсии, мой совет - вычислить ее как сумму ((x - среднее) ^ 2)/n, чтобы вы суммировали неотрицательные числа. среднее (x^2) - (mean(x))^2 может оказаться отрицательным из-за ошибок округления.   -  person Robert Dodier    schedule 06.07.2020
comment
Спасибо @ Роберт. которые исправили данные второго теста, вся статистика дисперсии теперь положительна и закодирована в виде чисел.   -  person pii_ke    schedule 06.07.2020
comment
О числах, закодированных как строки в JSON. Я думаю, мне придется прочитать о модулях bignum, Math::Big*.   -  person pii_ke    schedule 06.07.2020
comment
metacpan.org/pod/JSON::PP#simple-scalars выглядит актуально. JSON::PP будет кодировать неопределенные скаляры как нулевые значения JSON, скаляры, которые в последний раз использовались в строковом контексте перед кодированием как строки JSON, и все остальное как числовое значение:   -  person pii_ke    schedule 06.07.2020


Ответы (2)


Ни один из ваших входных данных не является достаточно большим, чтобы требовать JSON::PP для создания объектов Math::BigInt в системе с 64-битными целыми числами, поэтому это не так.

Вы можете сделать что-то вроде следующего в начале вашего sub.

@_ = map { Math::BigInt->new($_) } @_;   # Or ::BigFloat?

Альтернативно,

my $zero_B = Math::BigInt->new(0);

sub stats {
    my $n      = @_;
    my $sum_B  = sum($zero_B, @_);
    my $mean_B = $sum_B / $n;
    my $var_B  = sum( map { Math::BigInt->new($_) ** 2 } @_ ) / $n - $mean_B ** 2;
    my ($min, $max) = minmax(@_);
    return {
        n        => $n,
        sum      => $sum_B,
        max      => $max,
        min      => $min,
        mean     => $mean_B,
        variance => $var_B,
    };
}

Все вместе:

use strict;
use warnings;

use Data::Dumper    qw( Dumper );
use JSON::PP        qw( );
use List::MoreUtils qw( minmax );
use List::Util      qw( sum );
use Math::BigInt    qw( );

my $zero_B = Math::BigInt->new(0);
my $JSON = JSON::PP->new->allow_bignum->utf8->pretty->canonical;

sub stats {
    my $n      = @_;
    my $sum_B  = sum($zero_B, @_);
    my $mean_B = $sum_B / $n;
    my $var_B  = sum( map { Math::BigInt->new($_) ** 2 } @_ ) / $n - $mean_B ** 2;
    my ($min, $max) = minmax(@_);
    return {
        n        => $n,
        sum      => $sum_B,
        max      => $max,
        min      => $min,
        mean     => $mean_B,
        variance => $var_B,
    };
}

my @test = (
    [qw( 919300112739897344 919305709216464896 919305709216464896 985592115567603712 959299136196456448 )],
    [qw( 479655558 429035600 3281034608 3281034608 2606592908 3490045576 )],
    [ qw( 914426431563644928 ) x 3142 ]
);

for (@test) {
    print "---\n";
    my $s = stats( map { $JSON->decode($_) } @$_ );

    if (
           $JSON->encode($s->{variance}) =~ /"/  # MEAN ENCODED AS STRING
        || $JSON->encode($s->{mean}) =~ /"/      # VARIANCE ENCODED AS STRING
        || $s->{variance} < 0                    # VARIANCE IS NEGATIVE!
    ) {
        local $Data::Dumper::Varname = "DUMPED_RAWDATA";
        print Dumper($_);
        print $JSON->encode({
            json_encoded_rawdata => $_,
            json_encoded_stats => $s,
        });
    } else {
        print "ok\n";
    }
}

Примечания:

  • Оба подхода будут работать, даже если объекты уже являются объектами Math::*.
  • Я определил, что переменные гарантированно содержат объект Math:Big*, используя _B для ясности.
  • Я переместил тестовый код в тестовую обвязку.
  • Я использовал minmax, потому что это более эффективно, чем отдельные вызовы min и max.
  • Я импортировал сабвуферы из модулей, чтобы не использовать их полное имя.
  • Не нужно принудительно помещать что-то в скалярном контексте в скалярный контекст.
person ikegami    schedule 06.07.2020

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

sub stats {

    my $n = scalar @_;
    my $sum    = List::Util::sum0(@_);
    my $mean   = $sum / $n;
    my $var = List::Util::sum0( map { ( $_ - $mean )**2 } @_ ) / $n;
    $mean   += 0;
    $variance += 0;    # TO ENSURE THAT THEY ARE ENCODED AS NUMBERS IN JSON
    {
        n      => $n,
        sum    => $sum,
        max    => List::Util::max(@_),
        min    => List::Util::min(@_),
        mean   => $mean,
        variance => $var,
    };
}

Я изменил метод расчета дисперсии, чтобы избежать отрицательных результатов (как предложил @Robert). Это может пожертвовать точностью в $sum (и во всем, что зависит от $sum) из-за добавления больших целых чисел с плавающей запятой. Однако он завершает работу за приемлемое время выполнения.

Непреднамеренное кодирование чисел JSON в виде строк объясняется в https://metacpan.org/pod/JSON::PP#simple-scalars. Эта проблема решается с помощью предложенного там метода принудительного кодирования в виде чисел.

JSON::PP будет кодировать неопределенные скаляры как нулевые значения JSON, скаляры, которые в последний раз использовались в строковом контексте перед кодированием как строки JSON, и все остальное как числовое значение.

Вы можете заставить тип быть номером JSON, нумеровав его:

my $x = "3"; # some variable containing a string
$x += 0;     # numify it, ensuring it will be dumped as a number
$x *= 1;     # same thing, the choice is yours. in to force
person pii_ke    schedule 19.07.2020