Simulazione accurata di un sacco di lanci di dadi senza anelli?


14

OK, quindi se il tuo gioco lancia molti dadi, puoi semplicemente chiamare un generatore di numeri casuali in un ciclo. Ma per ogni serie di dadi lanciata abbastanza spesso otterrai una curva di distribuzione / istogramma. Quindi la mia domanda è un bel semplice calcolo che posso eseguire che mi darà un numero adatto a quella distribuzione?

Ad esempio 2D6 - Punteggio - Probabilità%

2 - 2.77%

3 - 5,55%

4 - 8.33%

5 - 11,11%

6 - 13.88%

7 - 16.66%

8 - 13.88%

9 - 11.11%

10 - 8,33%

11 - 5,55%

12 - 2,77%

Quindi, sapendo quanto sopra, potresti lanciare un singolo d100 ed elaborare un valore 2D6 accurato. Ma una volta che iniziamo con 10D6, 50D6, 100D6, 1000D6 questo potrebbe risparmiare molto tempo di elaborazione. Quindi ci deve essere un tutorial / metodo / algoritmo che può farlo velocemente? Probabilmente è utile per i mercati azionari, i casinò, i giochi di strategia, la fortezza nana, ecc. E se potessi simulare i risultati di una battaglia strategica completa che richiederebbe ore per giocare con alcune chiamate a questa funzione e alcuni calcoli di base?


5
Anche a 1000 d6, il loop sarà abbastanza veloce su un PC moderno che difficilmente lo noterai, quindi questa potrebbe essere un'ottimizzazione prematura. Prova sempre a profilare prima di sostituire un ciclo chiaro con una formula opaca. Detto questo, ci sono opzioni algoritmiche. Sei interessato a probabilità discrete come i dadi nei tuoi esempi o è accettabile modellarle come una distribuzione di probabilità continua (quindi potrebbe essere possibile un risultato frazionario come 2.5)?
DMGregory

DMGregory corretto, il calcolo di 1000d6 non sarà molto simile a un processore hog. Tuttavia, esiste una cosa chiamata distribuzione binomiale che (con un po 'di lavoro intelligente) otterrà il risultato che ti interessa. Inoltre, se mai vuoi trovare le probabilità di un set di regole di roll arbitrario, prova TRoll che ha un linguaggio modesto impostato per specificare come tirare un set di dadi e calcolerà tutte le probabilità per ogni possibile risultato.
Draco18s non si fida più di SE

Utilizzare una distribuzione di Poisson: p.
Luis Masuelli,

1
Per ogni serie di dadi lanciata abbastanza spesso probabilmente otterrai una curva / istogramma di distribuzione. Questa è una distinzione importante. Un dado può tirare un milione di 6 di fila, è improbabile, ma può farlo
Richard Tingle il

@RichardTingle Puoi elaborare? Una curva / istogramma di distribuzione includerà anche il caso "milioni 6s di fila".
entro il

Risposte:


16

Come ho già detto nel mio commento sopra, ti consiglio di profilare questo prima di complicare eccessivamente il tuo codice. Un fordado sommatore ad anello rapido è molto più facile da capire e modificare rispetto alle complicate formule matematiche e alla costruzione / ricerca di tabelle. Profila sempre per primo per assicurarti di risolvere i problemi importanti. ;)

Detto questo, ci sono due modi principali per campionare sofisticate distribuzioni di probabilità in un colpo solo:


1. Distribuzioni di probabilità cumulative

C'è un trucco per campionare da distribuzioni di probabilità continue usando solo un singolo input casuale uniforme . Ha a che fare con la distribuzione cumulativa , la funzione che risponde "Qual è la probabilità di ottenere un valore non maggiore di x?"

Questa funzione non è decrescente, a partire da 0 e passando a 1 sul suo dominio. Un esempio per la somma di due dadi a sei facce è mostrato sotto:

Grafici di probabilità, distribuzione cumulativa e inversa per 2d6

Se la tua funzione di distribuzione cumulativa ha un inverso comodo da calcolare (o puoi approssimarlo con funzioni a tratti come le curve di Bézier), puoi usarlo per campionare dalla funzione di probabilità originale.

La funzione inversa gestisce la suddivisione del dominio tra 0 e 1 in intervalli mappati su ciascun output del processo casuale originale, con l'area di captazione di ciascuno corrispondente alla sua probabilità originale. (Questo è vero all'infinito per le distribuzioni continue. Per distribuzioni discrete come i tiri di dado dobbiamo applicare un arrotondamento accurato)

Ecco un esempio dell'uso di questo per emulare 2d6:

int SimRoll2d6()
{
    // Get a random input in the half-open interval [0, 1).
    float t = Random.Range(0f, 1f);
    float v;

    // Piecewise inverse calculated by hand. ;)
    if(t <= 0.5f)
    {
         v = (1f + sqrt(1f + 288f * t)) * 0.5f;
    }
    else
    {
         v = (25f - sqrt(289f - 288f * t)) * 0.5f;
    }

    return floor(v + 1);
}

Confronta questo con:

int NaiveRollNd6(int n)
{
    int sum = 0;
    for(int i = 0; i < n; i++)
       sum += Random.Range(1, 7); // I'm used to Range never returning its max
    return sum;
}

Vedi cosa intendo per la differenza di chiarezza e flessibilità del codice? Il modo ingenuo potrebbe essere ingenuo con i suoi anelli, ma è breve e semplice, immediatamente ovvio su ciò che fa e facile da ridimensionare in base alle diverse dimensioni e numeri di matrici. Apportare modifiche al codice di distribuzione cumulativo richiede alcuni calcoli non banali e sarebbe facile rompere e causare risultati imprevisti senza errori evidenti. (Che spero di non aver fatto sopra)

Quindi, prima di eliminare un ciclo chiaro, assicurati assolutamente che sia davvero un problema di prestazioni degno di questo tipo di sacrificio.


2. Il metodo Alias

Il metodo di distribuzione cumulativa funziona bene quando puoi esprimere l'inverso della funzione di distribuzione cumulativa come una semplice espressione matematica, ma non è sempre facile o addirittura possibile. Un'alternativa affidabile per le distribuzioni discrete è qualcosa chiamato il metodo Alias .

Ciò consente di campionare da qualsiasi distribuzione di probabilità discreta arbitraria utilizzando solo due input casuali indipendenti, distribuiti uniformemente.

Funziona prendendo una distribuzione come quella in basso a sinistra (non preoccuparti che le aree / i pesi non si sommino a 1, per il metodo Alias ​​ci preoccupiamo del peso relativo ) e convertendolo in una tabella come quella in il giusto dove:

  • C'è una colonna per ogni risultato.
  • Ogni colonna è suddivisa in al massimo due parti, ognuna associata a uno dei risultati originali.
  • L'area / peso relativo di ciascun risultato viene preservato.

Esempio di metodo alias che converte una distribuzione in una tabella di ricerca

(Diagramma basato sulle immagini di questo eccellente articolo sui metodi di campionamento )

Nel codice, lo rappresentiamo con due tabelle (o una tabella di oggetti con due proprietà) che rappresentano la probabilità di scegliere il risultato alternativo da ciascuna colonna e l'identità (o "alias") di quel risultato alternativo. Quindi possiamo campionare dalla distribuzione in questo modo:

int SampleFromTables(float[] probabiltyTable, int[] aliasTable)
{
    int column = Random.Range(0, probabilityTable.Length);
    float p = Random.Range(0f, 1f);
    if(p < probabilityTable[column])
    {
        return column;
    }
    else
    {
        return aliasTable[column];
    }
}

Ciò comporta un po 'di configurazione:

  1. Calcola le probabilità relative di ogni possibile risultato (quindi se stai tirando 1000d6, dobbiamo calcolare il numero di modi per ottenere ogni somma da 1000 a 6000)

  2. Costruisci una coppia di tabelle con una voce per ogni risultato. Il metodo completo va oltre lo scopo di questa risposta, quindi consiglio vivamente di fare riferimento a questa spiegazione dell'algoritmo del metodo Alias .

  3. Conserva quelle tabelle e fai riferimento ad esse ogni volta che hai bisogno di un nuovo tiro di dado casuale da questa distribuzione.

Questo è un compromesso spazio-temporale . Il passaggio di pre-calcolo è alquanto esaustivo e dobbiamo mettere da parte la memoria proporzionata al numero di risultati che abbiamo (anche se anche per 1000d6, stiamo parlando di kilobyte a una cifra, quindi nulla su cui perdere il sonno), ma in cambio del nostro campionamento è un tempo costante, non importa quanto complessa potrebbe essere la nostra distribuzione.


Spero che l'uno o l'altro di questi metodi possa essere di qualche utilità (o che ti abbia convinto che la semplicità del metodo ingenuo vale il tempo necessario per eseguire il loop);)


1
Risposta fantastica. Mi piace l'approccio ingenuo però. Molto meno spazio per errori e facile da capire.
bummzack,

Cordiali saluti, questa domanda è una copia-incolla da una domanda casuale su reddit.
Vaillancourt

Per completezza, penso che questo sia il filo reddit di cui parla @AlexandreVaillancourt. Le risposte lì suggeriscono principalmente di mantenere la versione in loop (con alcune prove che il suo costo nel tempo è probabilmente ragionevole) o di approssimare un gran numero di dadi usando una distribuzione normale / gaussiana.
DMGregory

+1 per il metodo alias, sembra che così poche persone lo sappiano, ed è davvero la soluzione ideale per molti di questi tipi di situazioni di scelta probabilistica e +1 per menzionare la soluzione gaussiana, che è probabilmente il "migliore" rispondere se ci preoccupiamo solo di prestazioni e risparmio di spazio.
whn

0

La risposta purtroppo è che questo metodo non comporterebbe un aumento delle prestazioni.

Credo che ci possano essere dei malintesi nella domanda su come viene generato un numero casuale. Prendi l'esempio di seguito [Java]:

Random r = new Random();
int n = 20;
int min = 1; //arbitrary
int max = 6; //arbitrary
for(int i = 0; i < n; i++){
    int randomNumber = (r.nextInt(max - min + 1) + min)); //silly maths
    System.out.println("Here's a random number: " + randomNumber);
}

Questo codice eseguirà il ciclo 20 volte stampando numeri casuali tra 1 e 6 (incluso). Quando parliamo delle prestazioni di questo codice, ci vuole del tempo per creare l'oggetto Casuale (che comporta la creazione di una matrice di numeri interi pseudo-casuali basati sull'orologio interno del computer nel momento in cui è stato creato), e quindi 20 tempo costante ricerche su ogni chiamata nextInt (). Poiché ogni "rotolo" è un'operazione a tempo costante, ciò rende il rotolamento molto economico in termini di tempo. Notare anche che l'intervallo da min a max non ha importanza (in altre parole, è altrettanto facile per un computer tirare un d6 come farlo per un d10000). Parlando in termini di complessità temporale, la prestazione della soluzione è semplicemente O (n) dove n è il numero di dadi.

In alternativa, potremmo approssimare qualsiasi numero di rotoli d6 con un singolo rotolo d100 (o d10000 per quella materia). Usando questo metodo, dobbiamo prima calcolare le percentuali di s [numero di facce sui dadi] * n [numero di dadi] prima di tirare (tecnicamente sono s * n - n + 1 percentuali, e dovremmo essere in grado di dividerlo approssimativamente a metà poiché è simmetrico; nota che nel tuo esempio per la simulazione di un tiro 2d6, hai calcolato 11 percentuali e 6 erano uniche). Dopo il lancio possiamo usare una ricerca binaria per capire in quale intervallo rientra il nostro lancio. In termini di complessità temporale, questa soluzione restituisce una soluzione O (s * n), dove s è il numero di lati e n è il numero di dadi. Come possiamo vedere, questo è più lento della soluzione O (n) proposta nel paragrafo precedente.

Estrapolando da lì, supponi di aver creato entrambi questi programmi per simulare un tiro di 1000d20. Il primo sarebbe semplicemente rotolare 1.000 volte. Il secondo programma dovrebbe prima determinare le 19.001 percentuali (per la gamma potenziale da 1.000 a 20.000) prima di fare qualsiasi altra cosa. Quindi, a meno che non ci si trovi in ​​uno strano sistema in cui le ricerche di memoria sono molto più costose delle operazioni in virgola mobile, l'uso di una chiamata nextInt () per ogni lancio sembra la strada da percorrere.


2
L'analisi sopra non è del tutto corretta. Se riserviamo un po 'di tempo in anticipo per generare tabelle di probabilità e alias secondo il metodo Alias , possiamo campionare da una distribuzione di probabilità discreta arbitraria in tempo costante (2 numeri casuali e una ricerca di tabella). Quindi, simulare un tiro di 5 dadi o un tiro di 500 dadi richiede lo stesso lavoro, una volta preparati i tavoli. Questo è asintoticamente più veloce rispetto al ricorrere a un gran numero di dadi per ogni campione, anche se ciò non lo rende necessariamente una soluzione migliore al problema. ;)
DMGregory

0

Se vuoi conservare le combinazioni di dadi, la buona notizia è che esiste una soluzione, il male è che i nostri computer sono in qualche modo limitati rispetto a questo tipo di problemi.

Le buone notizie:

Esiste un approccio deterministico a questo problema:

1 / Calcola tutte le combinazioni del tuo gruppo di dadi

2 / Determina la probabilità per ogni combinazione

3 / Cerca in questo elenco un risultato invece di lanciare i dadi

Le cattive notizie:

Il numero di combinazioni con la ripetizione è dato dalle seguenti formule

ΓnK=(n+K-1K)=(n+K-1)!K! (n-1)!

( dal francese Wikipedia ):

Combinazione con ripetizioni

Ciò significa che, ad esempio, con 150 dadi, hai 698'526'906 combinazioni. Supponiamo che memorizzi la probabilità come float a 32 bit, avrai bisogno di 2,6 GB di memoria e dovrai comunque aggiungere requisiti di memoria per gli indici ...

In termini di calcolo, il numero di combinazione può essere calcolato da convoluzioni, il che è utile ma non risolve i vincoli di memoria.

In conclusione, per un numero elevato di dadi, consiglierei di lanciare i dadi e osservare il risultato piuttosto che pre-calcolare le probabilità associate a ciascuna combinazione.

modificare

Tuttavia, poiché sei interessato solo alla somma dei dadi, puoi archiviare le probabilità con molte meno risorse.

Puoi calcolare probabilità precise per ogni somma di dadi usando la convoluzione.

La formula generale è Fio(m)=ΣnF1(n)Fio-1(m-n)

Quindi a partire da 1/6 di ogni risultato con 1 dado, puoi costruire tutte le probabilità corrette per qualsiasi numero di dadi.

Ecco un codice java approssimativo che ho scritto per l'illustrazione (non proprio ottimizzato):

public class DiceProba {

private float[][] probas;
private int currentCalc;

public int getCurrentCalc() {
    return currentCalc;
}

public float[][] getProbas() {
    return probas;
}

public void calcProb(int faces, int diceNr) {

    if (diceNr < 0) {
        currentCalc = 0;
        return;
    }

    // Initialize
    float baseProba = 1.0f / ((float) faces);
    probas = new float[diceNr][];
    probas[0] = new float[faces + 1];
    probas[0][0] = 0.0f;
    for (int i = 1; i <= faces; ++i)
        probas[0][i] = baseProba;

    for (int i = 1; i < diceNr; ++i) {

        int maxValue = (i + 1) * faces + 1;
        probas[i] = new float[maxValue];

        for (int j = 0; j < maxValue; ++j) {

            probas[i][j] = 0;
            for (int k = 0; k <= j; ++k) {
                probas[i][j] += probability(faces, k, 0) * probability(faces, j - k, i - 1);
            }

        }

    }

    currentCalc = diceNr;

}

private float probability(int faces, int number, int diceNr) {

    if (number < 0 || number > ((diceNr + 1) * faces))
        return 0.0f;

    return probas[diceNr][number];

}

}

Chiama calcProb () con i parametri che desideri e quindi accedi alla tabella proba per i risultati (primo indice: 0 per 1 dado, 1 per due dadi ...).

L'ho verificato con 1'000D6 sul mio laptop, ci sono voluti 10 secondi per calcolare tutte le probabilità da 1 a 1'000 dadi e tutte le possibili somme di dadi.

Con una precomputazione e un'archiviazione efficiente, è possibile avere risposte rapide per un numero elevato di dadi.

Spero che sia d'aiuto.


3
Poiché OP sta solo cercando il valore della somma dei dadi, questa matematica combinatoria non si applica e il numero di voci della tabella di probabilità cresce linearmente con la dimensione dei dadi e con il numero di dadi.
DMGregory

Hai ragione ! Ho modificato la mia risposta. Siamo sempre intelligenti quando molti;)
elenfoiro78

Penso che puoi migliorare un po 'l'efficienza usando un approccio di divisione e conquista. Possiamo calcolare la tabella di probabilità per 20d6 contorcendo la tabella per 10d6 con se stessa. 10d6 possiamo trovare contorcendo la tabella 5d6 con se stessa. 5d6 possiamo trovare contorcendo le tabelle 2d6 e 3d6. Procedendo a metà in questo modo, possiamo saltare la generazione della maggior parte delle dimensioni del tavolo da 1 a 20 e concentrare i nostri sforzi su quelli interessanti.
DMGregory

1
E usa la simmetria!
elenfoiro78,
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.