Perché la modifica dell'ordine di somma restituisce un risultato diverso?


294

Perché la modifica dell'ordine di somma restituisce un risultato diverso?

23.53 + 5.88 + 17.64 = 47.05

23.53 + 17.64 + 5.88 = 47.050000000000004

Sia Java che JavaScript restituiscono gli stessi risultati.

Capisco che, a causa del modo in cui i numeri in virgola mobile sono rappresentati in binario, alcuni numeri razionali ( come 1/3 - 0.333333 ... ) non possono essere rappresentati con precisione.

Perché la semplice modifica dell'ordine degli elementi influisce sul risultato?


28
La somma dei numeri reali è associativa e commutativa. I virgola mobile non sono numeri reali. In effetti hai appena dimostrato che le loro operazioni non sono commutative. È abbastanza facile dimostrare che non sono troppo associativi (ad es (2.0^53 + 1) - 1 == 2.0^53 - 1 != 2^53 == 2^53 + (1 - 1).). Quindi sì: fai attenzione quando scegli l'ordine delle somme e altre operazioni. Alcuni linguaggi offrono un built-in per eseguire somme "di alta precisione" (ad es. Di Python math.fsum), quindi potresti prendere in considerazione l'uso di queste funzioni invece dell'algoritmo della somma ingenua.
Bakuriu,

1
@RBerteig Ciò può essere determinato esaminando l'ordine delle operazioni del linguaggio per le espressioni aritmetiche e, a meno che la loro rappresentazione dei numeri in virgola mobile nella memoria sia diversa, i risultati saranno gli stessi se le regole di precedenza dell'operatore sono le stesse. Un altro punto da notare: mi chiedo quanto tempo ci sono voluti gli sviluppatori che sviluppano applicazioni bancarie per capirlo? Quei centesimi extra 0000000000004 si sommano davvero!
Chris Cirefice,

3
@ChrisCirefice: se hai 0,00000004 centesimi , stai sbagliando. Non si dovrebbe mai usare un tipo binario a virgola mobile per i calcoli finanziari.
Daniel Pryden,

2
@DanielPryden Ah, ahimè, è stato uno scherzo ... semplicemente lanciando l'idea che le persone che hanno davvero bisogno di risolvere questo tipo di problema abbiano avuto uno dei lavori più importanti che conosci, detiene lo status monetario delle persone e tutto il resto . Ero molto sarcastico ...
Chris Cirefice il

Risposte:


276

Forse questa domanda è stupida, ma perché cambiare semplicemente l'ordine degli elementi influenza il risultato?

Cambierà i punti in cui i valori sono arrotondati, in base alla loro grandezza. Come esempio del tipo di cosa che stiamo vedendo, facciamo finta che invece del punto mobile binario, stessimo usando un tipo decimale a virgola mobile con 4 cifre significative, in cui ogni aggiunta viene eseguita con precisione "infinita" e quindi arrotondata a il numero rappresentabile più vicino. Ecco due somme:

1/3 + 2/3 + 2/3 = (0.3333 + 0.6667) + 0.6667
                = 1.000 + 0.6667 (no rounding needed!)
                = 1.667 (where 1.6667 is rounded to 1.667)

2/3 + 2/3 + 1/3 = (0.6667 + 0.6667) + 0.3333
                = 1.333 + 0.3333 (where 1.3334 is rounded to 1.333)
                = 1.666 (where 1.6663 is rounded to 1.666)

Non abbiamo nemmeno bisogno di numeri non interi perché questo sia un problema:

10000 + 1 - 10000 = (10000 + 1) - 10000
                  = 10000 - 10000 (where 10001 is rounded to 10000)
                  = 0

10000 - 10000 + 1 = (10000 - 10000) + 1
                  = 0 + 1
                  = 1

Ciò dimostra forse più chiaramente che la parte importante è che abbiamo un numero limitato di cifre significative , non un numero limitato di cifre decimali . Se potessimo sempre mantenere lo stesso numero di cifre decimali, almeno con l'aggiunta e la sottrazione, andremmo bene (purché i valori non traboccino). Il problema è che quando si arriva a numeri più grandi, si perdono informazioni più piccole, in questo caso il 10001 viene arrotondato a 10000. (Questo è un esempio del problema che Eric Lippert ha notato nella sua risposta .)

È importante notare che i valori sulla prima riga del lato destro sono gli stessi in tutti i casi, quindi anche se è importante capire che i tuoi numeri decimali (23.53, 5.88, 17.64) non saranno rappresentati esattamente come doublevalori, questo è solo un problema a causa dei problemi sopra indicati.


10
May extend this later - out of time right now!aspettando con impazienza @Jon
Prateek il

3
quando dico che tornerò a una risposta più tardi, la comunità è leggermente meno gentile con me <inserisci una specie di emoticon spensierata qui per mostrare che sto scherzando e non un coglione> ... tornerò su questo più tardi.
Grady Player

2
@ZongZhengLi: Anche se è certamente importante capirlo, in questo caso non è la causa principale. Potresti scrivere un esempio simile con valori che sono rappresentati esattamente in binario e vedere lo stesso effetto. Il problema qui è mantenere informazioni su larga scala e informazioni su piccola scala allo stesso tempo.
Jon Skeet,

1
@ Buksy: arrotondato a 10000 - perché abbiamo a che fare con un tipo di dati che può contenere solo 4 cifre significative. (quindi x.xxx * 10 ^ n)
Jon Skeet,

3
@meteors: No, non provoca un overflow - e stai usando i numeri sbagliati. È 10001 arrotondato a 10000, non 1001 arrotondato a 1000. Per renderlo più chiaro, 54321 verrebbe arrotondato a 54320, poiché ha solo quattro cifre significative. C'è una grande differenza tra "quattro cifre significative" e "un valore massimo di 9999". Come ho detto prima, in pratica stai rappresentando x.xxx * 10 ^ n, dove per 10000, x.xxx sarebbe 1.000 e n sarebbe 4. Questo è proprio come doublee float, dove per numeri molto grandi, numeri rappresentabili consecutivi sono più di 1 a parte.
Jon Skeet,

52

Ecco cosa sta succedendo in binario. Come sappiamo, alcuni valori in virgola mobile non possono essere rappresentati esattamente in binario, anche se possono essere rappresentati esattamente in decimali. Questi 3 numeri sono solo esempi di questo fatto.

Con questo programma ho prodotto le rappresentazioni esadecimali di ciascun numero e i risultati di ogni aggiunta.

public class Main{
   public static void main(String args[]) {
      double x = 23.53;   // Inexact representation
      double y = 5.88;    // Inexact representation
      double z = 17.64;   // Inexact representation
      double s = 47.05;   // What math tells us the sum should be; still inexact

      printValueAndInHex(x);
      printValueAndInHex(y);
      printValueAndInHex(z);
      printValueAndInHex(s);

      System.out.println("--------");

      double t1 = x + y;
      printValueAndInHex(t1);
      t1 = t1 + z;
      printValueAndInHex(t1);

      System.out.println("--------");

      double t2 = x + z;
      printValueAndInHex(t2);
      t2 = t2 + y;
      printValueAndInHex(t2);
   }

   private static void printValueAndInHex(double d)
   {
      System.out.println(Long.toHexString(Double.doubleToLongBits(d)) + ": " + d);
   }
}

Il printValueAndInHexmetodo è solo un aiuto per la stampante esadecimale.

L'output è il seguente:

403787ae147ae148: 23.53
4017851eb851eb85: 5.88
4031a3d70a3d70a4: 17.64
4047866666666666: 47.05
--------
403d68f5c28f5c29: 29.41
4047866666666666: 47.05
--------
404495c28f5c28f6: 41.17
4047866666666667: 47.050000000000004

I primi 4 numeri sono x, y, z, e s's rappresentazioni esadecimali. Nella rappresentazione in virgola mobile IEEE, i bit 2-12 rappresentano l' esponente binario , ovvero la scala del numero. (Il primo bit è il bit di segno e i bit rimanenti per la mantissa .) L'esponente rappresentato è in realtà il numero binario meno 1023.

Gli esponenti per i primi 4 numeri vengono estratti:

    sign|exponent
403 => 0|100 0000 0011| => 1027 - 1023 = 4
401 => 0|100 0000 0001| => 1025 - 1023 = 2
403 => 0|100 0000 0011| => 1027 - 1023 = 4
404 => 0|100 0000 0100| => 1028 - 1023 = 5

Prima serie di aggiunte

Il secondo numero ( y) è di grandezza minore. Quando si aggiungono questi due numeri per ottenere x + y, gli ultimi 2 bit del secondo numero ( 01) vengono spostati fuori dall'intervallo e non figurano nel calcolo.

La seconda aggiunta aggiunge x + ye zaggiunge due numeri della stessa scala.

Seconda serie di aggiunte

Qui, si x + zverifica per primo. Sono della stessa scala, ma producono un numero che è più in alto nella scala:

404 => 0|100 0000 0100| => 1028 - 1023 = 5

La seconda aggiunta aggiunge x + ze y, e ora vengono rilasciati 3 bit yper aggiungere i numeri ( 101). Qui, ci deve essere un arrotondamento verso l'alto, perché il risultato è il prossimo numero in virgola mobile verso l'alto: 4047866666666666per la prima serie di aggiunte rispetto 4047866666666667alla seconda serie di aggiunte. Tale errore è abbastanza significativo da mostrare nella stampa del totale.

In conclusione, fai attenzione quando esegui operazioni matematiche sui numeri IEEE. Alcune rappresentazioni sono inesatte e diventano ancora più inesatte quando le scale sono diverse. Aggiungi e sottrai numeri di scala simile se puoi.


Le scale sono diverse è la parte importante. È possibile scrivere (in decimali) i valori esatti che vengono rappresentati in binario come input e che presentano ancora lo stesso problema.
Jon Skeet,

@rgettman Come programmatore, mi piace la tua risposta migliore =)+1 per il tuo aiutante della stampante esadecimale ... è davvero bello!
ADTC

44

La risposta di Jon è ovviamente corretta. Nel tuo caso l'errore non è più grande dell'errore che accumuleresti facendo qualsiasi semplice operazione in virgola mobile. Hai uno scenario in cui in un caso ricevi zero errori e in un altro ricevi un piccolo errore; non è uno scenario così interessante. Una buona domanda è: esistono scenari in cui la modifica dell'ordine dei calcoli passa da un piccolo errore a un errore (relativamente) enorme? La risposta è inequivocabilmente sì.

Considera ad esempio:

x1 = (a - b) + (c - d) + (e - f) + (g - h);

vs

x2 = (a + c + e + g) - (b + d + f + h);

vs

x3 = a - b + c - d + e - f + g - h;

Ovviamente nell'aritmetica esatta sarebbero gli stessi. È divertente cercare di trovare valori per a, b, c, d, e, f, g, h in modo tale che i valori di x1 e x2 e x3 differiscano per una grande quantità. Vedi se riesci a farlo!


Come si definisce una grande quantità? Stiamo parlando dell'ordine dei millesimi? 100ths? 1 di ???
Cruncher,

3
@ Cruncher: calcola l'esatto risultato matematico e i valori x1 e x2. Chiama l'esatta differenza matematica tra i risultati veri e calcolati e1 ed e2. Esistono ora diversi modi per pensare alla dimensione dell'errore. Il primo è: riesci a trovare uno scenario in cui | e1 / e2 | o | e2 / e1 | sono grandi? Ad esempio, puoi fare l'errore di uno dieci volte l'errore dell'altro? Il più interessante è se riesci a rendere l'errore di una frazione significativa della dimensione della risposta corretta.
Eric Lippert,

1
Mi rendo conto che sta parlando di runtime, ma mi chiedo: se l'espressione fosse un'espressione in fase di compilazione (diciamo, constexpr), i compilatori sono abbastanza intelligenti da ridurre al minimo l'errore?
Kevin Hsu,

@kevinhsu in generale no, il compilatore non è così intelligente. Naturalmente il compilatore potrebbe scegliere di eseguire l'operazione in modo aritmetico esatto se lo scegliesse, ma di solito non lo fa.
Eric Lippert,

8
@frozenkoi: Sì, l'errore può essere infinito molto facilmente. Ad esempio, considera C #: double d = double.MaxValue; Console.WriteLine(d + d - d - d); Console.WriteLine(d - d + d - d);- l'output è Infinity quindi 0.
Jon Skeet

10

Questo in realtà copre molto più di Java e Javascript e probabilmente influenzerebbe qualsiasi linguaggio di programmazione usando float o double.

In memoria, i punti mobili utilizzano un formato speciale lungo le linee di IEEE 754 (il convertitore fornisce una spiegazione molto migliore di quanto io possa).

Comunque, ecco il convertitore float.

http://www.h-schmidt.net/FloatConverter/

La cosa sull'ordine delle operazioni è la "finezza" dell'operazione.

La tua prima riga produce 29,41 dai primi due valori, che ci dà 2 ^ 4 come esponente.

La tua seconda riga produce 41,17 che ci dà 2 ^ 5 come esponente.

Stiamo perdendo una cifra significativa aumentando l'esponente, che probabilmente cambierà il risultato.

Prova a spuntare l'ultimo bit all'estrema destra per attivare e disattivare 41.17 e puoi vedere che qualcosa di "insignificante" come 1/2 ^ 23 dell'esponente sarebbe sufficiente a causare questa differenza in virgola mobile.

Modifica: per quelli di voi che ricordano cifre significative, questo rientra in quella categoria. 10 ^ 4 + 4999 con una cifra significativa di 1 sarà 10 ^ 4. In questo caso, la cifra significativa è molto più piccola, ma possiamo vedere i risultati con .00000000004 ad esso collegato.


9

I numeri in virgola mobile sono rappresentati utilizzando il formato IEEE 754, che fornisce una dimensione specifica di bit per la mantissa (significato). Sfortunatamente questo ti dà un numero specifico di "blocchi frazionari" con cui giocare, e alcuni valori frazionari non possono essere rappresentati con precisione.

Ciò che sta accadendo nel tuo caso è che nel secondo caso, probabilmente l'aggiunta sta incontrando un problema di precisione a causa dell'ordine in cui le aggiunte vengono valutate. Non ho calcolato i valori, ma potrebbe essere per esempio che 23.53 + 17.64 non possano essere rappresentati con precisione, mentre 23.53 + 5.88 possono.

Purtroppo è un problema noto che devi solo affrontare.


6

Credo che abbia a che fare con l'ordine di evacuazione. Mentre la somma è naturalmente la stessa in un mondo matematico, nel mondo binario invece di A + B + C = D, è

A + B = E
E + C = D(1)

Quindi c'è quel passaggio secondario in cui i numeri in virgola mobile possono scendere.

Quando cambi l'ordine,

A + C = F
F + B = D(2)

4
Penso che questa risposta eviti il ​​vero motivo. "c'è quel passaggio secondario in cui i numeri in virgola mobile possono scendere". Chiaramente, questo è vero, ma ciò che vogliamo spiegare è il perché .
Zong,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.