In quale ordine devono essere aggiunti i galleggianti per ottenere il risultato più preciso?


105

Questa era una domanda che mi è stata posta durante la mia recente intervista e voglio sapere (in realtà non ricordo la teoria dell'analisi numerica, quindi per favore aiutami :)

Se abbiamo qualche funzione, che accumula numeri in virgola mobile:

std::accumulate(v.begin(), v.end(), 0.0);

vè un std::vector<float>, per esempio.

  • Sarebbe meglio ordinare questi numeri prima di accumularli?

  • Quale ordine darebbe la risposta più precisa?

Ho il sospetto che l'ordinamento i numeri in ordine crescente sarebbe in realtà fare l'errore numerico di meno , ma purtroppo non posso provarlo io stesso.

PS Mi rendo conto che questo probabilmente non ha nulla a che fare con la programmazione del mondo reale, solo essere curioso.


17
Questo in realtà ha tutto a che fare con la programmazione del mondo reale. Tuttavia, molte applicazioni non si preoccupano della massima precisione assoluta del calcolo fintanto che è "abbastanza vicino". Applicazioni ingegneristiche? Estremamente importante. Applicazioni mediche? Estremamente importante. Statistiche su larga scala? È accettabile una precisione leggermente inferiore.
Zéychin

18
Per favore, non rispondere a meno che tu non sappia e puoi puntare a una pagina che spiega il tuo ragionamento in dettaglio. Ci sono già così tante stronzate sui numeri in virgola mobile che volano in giro che non vogliamo aggiungere. Se pensi di sapere. FERMARE. perché se pensi di sapere solo allora probabilmente ti sbagli.
Martin York

4
@ Zéychin "Applicazioni ingegneristiche? Estremamente importanti. Applicazioni mediche? Estremamente importanti." ??? Penso che saresti sorpreso se conoscessi la verità :)
BЈовић

3
@Zeychin L'errore assoluto è irrilevante. Ciò che è importante è l'errore relativo. Se pochi centesimi di radiante sono 0,001%, a chi importa?
BЈовић

3
Consiglio vivamente questa lettura: "quello che ogni scienziato informatico deve sapere sulla virgola mobile" perso.ens-lyon.fr/jean-michel.muller/goldberg.pdf
Mohammad Alaggan

Risposte:


108

Il tuo istinto è fondamentalmente giusto, l'ordinamento in ordine crescente (di grandezza) di solito migliora un po 'le cose. Considera il caso in cui stiamo aggiungendo float a precisione singola (32 bit) e ci sono 1 miliardo di valori uguali a 1 / (1 miliardo) e un valore uguale a 1. Se 1 viene prima, la somma verrà a 1, poiché 1 + (1/1 miliardo) è 1 a causa della perdita di precisione. Ogni aggiunta non ha alcun effetto sul totale.

Se i valori piccoli vengono prima, si sommeranno almeno a qualcosa, anche se anche in questo caso ne ho 2 ^ 30, mentre dopo 2 ^ 25 circa sono tornato nella situazione in cui ciascuno individualmente non influisce sul totale più. Quindi avrò ancora bisogno di altri trucchi.

Questo è un caso estremo, ma in generale l'aggiunta di due valori di grandezza simile è più accurata dell'aggiunta di due valori di grandezze molto diverse, dal momento che in questo modo "scarti" meno bit di precisione nel valore più piccolo. Ordinando i numeri, si raggruppano insieme valori di grandezza simile, e aggiungendoli in ordine crescente si dà ai valori piccoli una "possibilità" di raggiungere cumulativamente la grandezza dei numeri più grandi.

Tuttavia, se sono coinvolti numeri negativi, è facile "superare in astuzia" questo approccio. Consideriamo tre valori per riassumere, {1, -1, 1 billionth}. La somma aritmeticamente corretta è 1 billionth, ma se la mia prima aggiunta coinvolge il valore minuscolo, la mia somma finale sarà 0. Dei 6 possibili ordini, solo 2 sono "corretti" - {1, -1, 1 billionth}e {-1, 1, 1 billionth}. Tutti e 6 gli ordini danno risultati accurati alla scala del valore di grandezza massima nell'input (0,0000001% in uscita), ma per 4 di essi il risultato è impreciso alla scala della vera soluzione (100% in uscita). Il problema particolare che stai risolvendo ti dirà se il primo è abbastanza buono o meno.

In effetti, puoi giocare molti più trucchi che aggiungerli semplicemente in ordine ordinato. Se hai molti valori molto piccoli, un numero medio di valori medi e un numero piccolo di valori grandi, allora potrebbe essere più accurato prima sommare tutti i valori piccoli, quindi sommare separatamente quelli medi, aggiungere quei due totali insieme poi aggiungi quelli grandi. Non è affatto banale trovare la combinazione più accurata di aggiunte in virgola mobile, ma per far fronte a casi veramente brutti puoi mantenere un'intera serie di totali correnti a diverse grandezze, aggiungere ogni nuovo valore al totale che meglio corrisponde alla sua grandezza, e quando un totale parziale inizia a diventare troppo grande per la sua grandezza, aggiungilo al totale successivo e avviane uno nuovo. Portato al suo estremo logico, questo processo equivale a eseguire la somma in un tipo di precisione arbitraria (quindi si ' farlo). Ma data la scelta semplicistica di aggiungere in ordine di grandezza crescente o decrescente, l'ascesa è la soluzione migliore.

Ha qualche relazione con la programmazione del mondo reale, poiché ci sono alcuni casi in cui il tuo calcolo può andare molto male se tagli accidentalmente una coda "pesante" composta da un gran numero di valori ognuno dei quali è troppo piccolo per essere influenzato individualmente la somma, o se si butta via troppa precisione da molti piccoli valori che singolarmente influenzano solo gli ultimi bit della somma. Nei casi in cui la coda è comunque trascurabile, probabilmente non ti interessa. Ad esempio, se stai sommando solo un piccolo numero di valori in primo luogo e stai usando solo poche cifre significative della somma.


8
+1 per la spiegazione. Questo è in qualche modo controintuitivo poiché l'addizione è solitamente numericamente stabile (a differenza della sottrazione e della divisione).
Konrad Rudolph

2
@ Konrad, potrebbe essere numericamente stabile, ma non è preciso date le diverse magnitudini di operandi :)
MSN

3
@ 6502: sono ordinati in ordine di grandezza, quindi il -1 arriva alla fine. Se il valore vero del totale è di magnitudine 1, allora va bene. Se stai sommando tre valori: 1 / miliardo, 1 e -1, otterrai 0, a quel punto devi rispondere all'interessante domanda pratica: hai bisogno di una risposta che sia accurata alla scala del somma vera o hai solo bisogno di una risposta accurata alla scala dei valori più grandi? Per alcune applicazioni pratiche, quest'ultimo è abbastanza buono, ma quando non lo è è necessario un approccio più sofisticato. La fisica quantistica utilizza la rinormalizzazione.
Steve Jessop

8
Se ti atterrai a questo semplice schema, aggiungerei sempre i due numeri con la magnitudine più bassa e reinserirei la somma nel set. (Beh, probabilmente un merge sort funzionerebbe meglio qui. Potresti usare la parte dell'array contenente i numeri sommati in precedenza come area di lavoro per le somme parziali.)
Neil

2
@ Kevin Panko: La versione semplice è che un float a precisione singola ha 24 cifre binarie, la più grande delle quali è il bit impostato più grande nel numero. Quindi, se si sommano due numeri che differiscono in grandezza di oltre 2 ^ 24, si subisce la perdita totale del valore più piccolo e se differiscono in grandezza di un grado inferiore, si perde un numero corrispondente di bit di precisione del valore più piccolo numero.
Steve Jessop

88

Esiste anche un algoritmo progettato per questo tipo di operazione di accumulo, chiamato Kahan Summation , di cui probabilmente dovresti essere a conoscenza.

Secondo Wikipedia,

L' algoritmo di somma di Kahan (noto anche come somma compensata ) riduce significativamente l'errore numerico nel totale ottenuto aggiungendo una sequenza di numeri in virgola mobile a precisione finita, rispetto all'approccio ovvio. Questo viene fatto mantenendo una compensazione in esecuzione separata (una variabile per accumulare piccoli errori).

In pseudocodice, l'algoritmo è:

function kahanSum(input)
 var sum = input[1]
 var c = 0.0          //A running compensation for lost low-order bits.
 for i = 2 to input.length
  y = input[i] - c    //So far, so good: c is zero.
  t = sum + y         //Alas, sum is big, y small, so low-order digits of y are lost.
  c = (t - sum) - y   //(t - sum) recovers the high-order part of y; subtracting y recovers -(low part of y)
  sum = t             //Algebraically, c should always be zero. Beware eagerly optimising compilers!
 next i               //Next time around, the lost low part will be added to y in a fresh attempt.
return sum

3
+1 bella aggiunta a questo thread. Qualsiasi compilatore che "ottimizza con entusiasmo" tali dichiarazioni dovrebbe essere bandito.
Chris A.

1
È un metodo semplice per raddoppiare quasi la precisione, utilizzando due variabili di somma sume cdi diversa grandezza. Può essere banalmente esteso a N variabili.
MSalters

2
@ChrisA. beh, puoi controllarlo esplicitamente su tutti i compilatori che contano (ad esempio tramite -ffast-mathsu GCC).
Konrad Rudolph

6
@ Konrad Rudolph grazie per aver sottolineato che questa è una possibile ottimizzazione con -ffast-math. Quello che ho imparato da questa discussione e da questo collegamento , è che se ti interessa l'accuratezza numerica dovresti probabilmente evitare di usarlo, -ffast-mathma in molte applicazioni in cui potresti essere legato alla CPU ma non ti interessano calcoli numerici precisi, (programmazione di giochi per esempio ), -ffast-mathè ragionevole da usare. Pertanto, vorrei modificare il mio commento "vietato" fortemente formulato.
Chris A.

L'uso di variabili a doppia precisione per sum, c, t, yaiuterà. Devi anche aggiungere sum -= cprima return sum.
G. Cohen

34

Ho provato l'esempio estremo nella risposta fornita da Steve Jessop.

#include <iostream>
#include <iomanip>
#include <cmath>

int main()
{
    long billion = 1000000000;
    double big = 1.0;
    double small = 1e-9;
    double expected = 2.0;

    double sum = big;
    for (long i = 0; i < billion; ++i)
        sum += small;
    std::cout << std::scientific << std::setprecision(1) << big << " + " << billion << " * " << small << " = " <<
        std::fixed << std::setprecision(15) << sum <<
        "    (difference = " << std::fabs(expected - sum) << ")" << std::endl;

    sum = 0;
    for (long i = 0; i < billion; ++i)
        sum += small;
    sum += big;
    std::cout  << std::scientific << std::setprecision(1) << billion << " * " << small << " + " << big << " = " <<
        std::fixed << std::setprecision(15) << sum <<
        "    (difference = " << std::fabs(expected - sum) << ")" << std::endl;

    return 0;
}

Ho ottenuto il seguente risultato:

1.0e+00 + 1000000000 * 1.0e-09 = 2.000000082740371    (difference = 0.000000082740371)
1000000000 * 1.0e-09 + 1.0e+00 = 1.999999992539933    (difference = 0.000000007460067)

L'errore nella prima riga è più di dieci volte maggiore nella seconda.

Se cambio la doubles in floats nel codice sopra, ottengo:

1.0e+00 + 1000000000 * 1.0e-09 = 1.000000000000000    (difference = 1.000000000000000)
1000000000 * 1.0e-09 + 1.0e+00 = 1.031250000000000    (difference = 0.968750000000000)

Nessuna delle due risposte è nemmeno vicina a 2.0 (ma la seconda è leggermente più vicina).

Usando la somma Kahan (con doubles) come descritto da Daniel Pryden:

#include <iostream>
#include <iomanip>
#include <cmath>

int main()
{
    long billion = 1000000000;
    double big = 1.0;
    double small = 1e-9;
    double expected = 2.0;

    double sum = big;
    double c = 0.0;
    for (long i = 0; i < billion; ++i) {
        double y = small - c;
        double t = sum + y;
        c = (t - sum) - y;
        sum = t;
    }

    std::cout << "Kahan sum  = " << std::fixed << std::setprecision(15) << sum <<
        "    (difference = " << std::fabs(expected - sum) << ")" << std::endl;

    return 0;
}

Ottengo esattamente 2.0:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

E anche se cambio la doubles in floats nel codice sopra, ottengo:

Kahan sum  = 2.000000000000000    (difference = 0.000000000000000)

Sembrerebbe che Kahan sia la strada da percorrere!


Il mio valore "grande" è uguale a 1, non 1e9. La tua seconda risposta, aggiunta in ordine crescente di grandezza, è matematicamente corretta (1 miliardo, più un miliardesimo miliardesimo, è 1 miliardo e 1), anche se più per fortuna qualsiasi validità generale del metodo :-) Nota che doublenon soffre di male perdita di precisione nella somma di un miliardo di miliardesimi, poiché ha 52 bit significativi, mentre IEEE floatne ha solo 24 e lo farebbe.
Steve Jessop

@ Steve, il mio errore, scuse. Ho aggiornato il codice di esempio a quello che intendevi.
Andrew Stein

4
Kahan ha ancora una precisione limitata, ma per costruire un caso killer è necessario che sia la somma principale sia l'accumulatore di errori ccontengano valori molto più grandi del prossimo addendo. Ciò significa che il sommario è molto, molto più piccolo della somma principale, quindi ce ne saranno molti per sommare molto. Soprattutto con l' doublearitmetica.
Steve Jessop

14

Esiste una classe di algoritmi che risolvono questo problema esatto, senza la necessità di ordinare o riordinare in altro modo i dati .

In altre parole, la somma può essere eseguita in un passaggio sui dati. Ciò rende tali algoritmi applicabili anche in situazioni in cui il set di dati non è noto in anticipo, ad esempio se i dati arrivano in tempo reale e la somma parziale deve essere mantenuta.

Ecco l'abstract di un recente articolo:

Presentiamo un nuovo algoritmo online per la somma esatta di un flusso di numeri in virgola mobile. Con "online" si intende che l'algoritmo deve vedere un solo input alla volta e può accettare un flusso di input di lunghezza arbitraria di tali input richiedendo solo una memoria costante. Per "esatto" si intende che la somma dell'array interno del nostro algoritmo è esattamente uguale alla somma di tutti gli input, e il risultato restituito è la somma arrotondata correttamente. La prova di correttezza è valida per tutti gli input (inclusi i numeri non normalizzati ma l'overflow intermedio del modulo) ed è indipendente dal numero di addendi o dal numero di condizione della somma. L'algoritmo richiede asintoticamente solo 5 FLOP per addendo e, a causa del parallelismo a livello di istruzione, viene eseguito solo circa 2-3 volte più lentamente dell'ovvio, ciclo veloce ma stupido di "sommatoria ricorsiva ordinaria" quando il numero di addendi è maggiore di 10.000. Pertanto, per quanto ne sappiamo, è l'algoritmo più veloce, accurato ed efficiente in termini di memoria tra gli algoritmi conosciuti. In effetti, è difficile vedere come possa esistere un algoritmo più veloce o uno che richiede un numero significativamente inferiore di FLOP senza miglioramenti hardware. Viene fornita un'applicazione per un gran numero di somme.

Fonte: algoritmo 908: somma esatta online di flussi in virgola mobile .


1
@Inverse: ci sono ancora biblioteche di mattoni e malta in giro. In alternativa, l'acquisto del PDF online costa $ 5- $ 15 (a seconda che tu sia un membro ACM). Infine, DeepDyve sembra offrirsi di prestare il giornale per 24 ore per $ 2,99 (se sei nuovo su DeepDyve, potresti persino essere in grado di ottenerlo gratuitamente come parte della loro prova gratuita): deepdyve.com/lp/acm /…
NPE

2

Basandosi sulla risposta di Steve di ordinare prima i numeri in ordine crescente, introdurrei altre due idee:

  1. Decidi la differenza di esponente di due numeri al di sopra del quale potresti decidere di perdere troppa precisione.

  2. Quindi sommare i numeri in ordine fino a quando l'esponente dell'accumulatore è troppo grande per il numero successivo, quindi mettere l'accumulatore in una coda temporanea e avviare l'accumulatore con il numero successivo. Continua fino a esaurire l'elenco originale.

Ripeti il ​​processo con la coda temporanea (dopo averla ordinata) e con una differenza di esponente forse maggiore.

Penso che sarà piuttosto lento se devi calcolare sempre gli esponenti.

Ho provato rapidamente un programma e il risultato è stato 1.99903


2

Penso che tu possa fare di meglio che ordinare i numeri prima di accumularli, perché durante il processo di accumulazione, l'accumulatore diventa sempre più grande. Se hai una grande quantità di numeri simili, inizierai a perdere rapidamente la precisione. Ecco cosa suggerirei invece:

while the list has multiple elements
    remove the two smallest elements from the list
    add them and put the result back in
the single element in the list is the result

Ovviamente questo algoritmo sarà più efficiente con una coda di priorità invece di un elenco. Codice C ++:

template <typename Queue>
void reduce(Queue& queue)
{
    typedef typename Queue::value_type vt;
    while (queue.size() > 1)
    {
        vt x = queue.top();
        queue.pop();
        vt y = queue.top();
        queue.pop();
        queue.push(x + y);
    }
}

autista:

#include <iterator>
#include <queue>

template <typename Iterator>
typename std::iterator_traits<Iterator>::value_type
reduce(Iterator begin, Iterator end)
{
    typedef typename std::iterator_traits<Iterator>::value_type vt;
    std::priority_queue<vt> positive_queue;
    positive_queue.push(0);
    std::priority_queue<vt> negative_queue;
    negative_queue.push(0);
    for (; begin != end; ++begin)
    {
        vt x = *begin;
        if (x < 0)
        {
            negative_queue.push(x);
        }
        else
        {
            positive_queue.push(-x);
        }
    }
    reduce(positive_queue);
    reduce(negative_queue);
    return negative_queue.top() - positive_queue.top();
}

I numeri in coda sono negativi perché toprestituisce il numero maggiore , ma noi vogliamo il numero minore . Avrei potuto fornire più argomenti del modello alla coda, ma questo approccio sembra più semplice.


2

Questo non risponde perfettamente alla tua domanda, ma una cosa intelligente da fare è eseguire la somma due volte, una volta con la modalità di arrotondamento "round up" e una volta con "round down". Confronta le due risposte e sai / come / sono imprecisi i tuoi risultati e se devi quindi utilizzare una strategia di somma più intelligente. Sfortunatamente, la maggior parte delle lingue non rende la modifica della modalità di arrotondamento in virgola mobile facile come dovrebbe essere, perché le persone non sanno che è effettivamente utile nei calcoli di tutti i giorni.

Dai un'occhiata all'aritmetica degli intervalli in cui fai tutti i calcoli in questo modo, mantenendo i valori più alti e più bassi mentre procedi. Porta ad alcuni risultati e ottimizzazioni interessanti.


0

L' ordinamento più semplice che migliora la precisione è l'ordinamento in base al valore assoluto crescente. Ciò consente ai valori di magnitudo più piccoli di accumularsi o annullarsi prima di interagire con valori di magnitudo più grandi che avrebbero innescato una perdita di precisione.

Detto questo, puoi fare di meglio monitorando più somme parziali non sovrapposte. Ecco un documento che descrive la tecnica e presenta una prova di accuratezza: www-2.cs.cmu.edu/afs/cs/project/quake/public/papers/robust-arithmetic.ps

Quell'algoritmo e altri approcci alla somma esatta in virgola mobile sono implementati in semplice Python all'indirizzo: http://code.activestate.com/recipes/393090/ Almeno due di questi possono essere banalmente convertiti in C ++.


0

Per i numeri IEEE 754 a precisione singola o doppia o in formato noto, un'altra alternativa consiste nell'usare un array di numeri (passati dal chiamante o in una classe per C ++) indicizzati dall'esponente. Quando si aggiungono numeri nell'array, vengono aggiunti solo numeri con lo stesso esponente (fino a quando non viene trovato uno slot vuoto e il numero memorizzato). Quando viene richiesta una somma, l'array viene sommato dal più piccolo al più grande per ridurre al minimo il troncamento. Esempio di precisione singola:

/* clear array */
void clearsum(float asum[256])
{
size_t i;
    for(i = 0; i < 256; i++)
        asum[i] = 0.f;
}

/* add a number into array */
void addtosum(float f, float asum[256])
{
size_t i;
    while(1){
        /* i = exponent of f */
        i = ((size_t)((*(unsigned int *)&f)>>23))&0xff;
        if(i == 0xff){          /* max exponent, could be overflow */
            asum[i] += f;
            return;
        }
        if(asum[i] == 0.f){     /* if empty slot store f */
            asum[i] = f;
            return;
        }
        f += asum[i];           /* else add slot to f, clear slot */
        asum[i] = 0.f;          /* and continue until empty slot */
    }
}

/* return sum from array */
float returnsum(float asum[256])
{
float sum = 0.f;
size_t i;
    for(i = 0; i < 256; i++)
        sum += asum[i];
    return sum;
}

esempio di doppia precisione:

/* clear array */
void clearsum(double asum[2048])
{
size_t i;
    for(i = 0; i < 2048; i++)
        asum[i] = 0.;
}

/* add a number into array */
void addtosum(double d, double asum[2048])
{
size_t i;
    while(1){
        /* i = exponent of d */
        i = ((size_t)((*(unsigned long long *)&d)>>52))&0x7ff;
        if(i == 0x7ff){         /* max exponent, could be overflow */
            asum[i] += d;
            return;
        }
        if(asum[i] == 0.){      /* if empty slot store d */
            asum[i] = d;
            return;
        }
        d += asum[i];           /* else add slot to d, clear slot */
        asum[i] = 0.;           /* and continue until empty slot */
    }
}

/* return sum from array */
double returnsum(double asum[2048])
{
double sum = 0.;
size_t i;
    for(i = 0; i < 2048; i++)
        sum += asum[i];
    return sum;
}

Questo suona un po 'come il metodo di Malcolm 1971 o, più ancora, la sua variante che utilizza l'esponente di Demmel e Hida ("Algorithm 3"). C'è un altro algoritmo là fuori che esegue un loop basato sul carry come il tuo, ma al momento non riesco a trovarlo.
ZachB

@ZachB - il concetto è simile all'ordinamento di tipo bottom up per l'elenco collegato , che utilizza anche un piccolo array, dove array [i] punta all'elenco con 2 ^ i nodi. Non so quanto lontano sia questo. Nel mio caso, è stata la scoperta di me stesso negli anni '70.
rcgldr

-1

I tuoi float dovrebbero essere aggiunti con doppia precisione. Questo ti darà maggiore precisione rispetto a qualsiasi altra tecnica. Per un po 'più di precisione e significativamente più velocità, puoi creare, diciamo, quattro somme e sommarle alla fine.

Se stai aggiungendo numeri a doppia precisione, usa long double per la somma - tuttavia, questo avrà un effetto positivo solo nelle implementazioni in cui long double ha effettivamente più precisione del double (tipicamente x86, PowerPC a seconda delle impostazioni del compilatore).


1
"Questo ti darà maggiore precisione rispetto a qualsiasi altra tecnica" Ti rendi conto che la tua risposta arriva più di un anno dopo una precedente risposta tardiva che descriveva come usare la somma esatta?
Pascal Cuoq

Il tipo "doppio lungo" è orribile e non dovresti usarlo.
Jeff

-1

Per quanto riguarda l'ordinamento, mi sembra che se ti aspetti la cancellazione, i numeri dovrebbero essere aggiunti in ordine decrescente di grandezza, non crescente. Per esempio:

((-1 + 1) + 1e-20) darà 1e-20

ma

((1e-20 + 1) - 1) darà 0

Nella prima equazione vengono cancellati due numeri grandi, mentre nella seconda il termine 1e-20 si perde quando aggiunto a 1, poiché non c'è abbastanza precisione per mantenerlo.

Inoltre, la somma a coppie è abbastanza decente per sommare molti numeri.

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.