Java – Generare valori “casuali” con una certa probabilità

Nel precedente articolo Generazione di numeri “casuali” ho descritto le funzionalità che il framework standard di Java mette a disposizione per generare dei numeri “casuali”.

Ci possono essere delle situazioni particolari in cui c’è bisogno di estrarre dei valori casuali che abbiano una certa probabilità specifica di uscita. Esistono due esempi molto classici: la moneta “truccata” e il dado “truccato”. In questo articolo descriverò questi due casi mostrando anche del codice concreto di prova.

La moneta truccata

Con una moneta “truccata” tipicamente si vuole che le probabilità che esca “testa” o “croce” non siano esattamente pari al 50% ma qualcosa di differente, ad esempio 30% di probabilità per “testa” e 70% di probabilità per “croce”.

Realizzarlo con un generatore di numeri (pseudo) casuali non è affatto difficile. Nello scenario appena descritto sarebbe sufficiente estrarre un numero intero casuale compreso tra 0 e 9 (entrambi inclusi) e poi se si ottiene 0, 1 o 2 (cioè minore di 3) allora si cade proprio in quel 30% di probabilità (altrimenti è nel restante 70%). Il concetto generale quindi è molto semplice: basta raggruppare più o meno valori dell’intervallo scelto per avere più o meno probabilità in proporzione.

 0  1  2  3  4  5  6  7  8  9
\       /\                   /
 \_____/  \_________________/
   30%            70%
  testa          croce

L’esempio appena fatto utilizza dei valori interi ma, se si volesse generalizzare, è anche possibile specificare la percentuale come valore decimale (es. 35,6%). In tal caso sarebbe sufficiente semplicemente trattare valori di tipo double e utilizzare un intervallo di valori casuali compresi tra 0.0 (incluso) e 100.0 (escluso).

Uno stralcio di codice molto basilare (che usa numeri interi) può essere questo:

for (int n = 0; n < 50; n++) {
    int v = (int) (Math.random() * 10);   // valore tra 0 e 9 compresi

    if (v < 3) {
        System.out.println("Testa");   // ha il 30% di probabilità
    } else {
        System.out.println("Croce");   // ha il 70% di probabilità
    }
}

Esempio completo

Il mio obiettivo però è di generalizzare un pochino questa logica e di fare inoltre un programmino completo (con il main di avvio) che stampi anche la statistica reale su una sequenza di estrazioni di “testa”/“croce”.

La mia idea è di fare innanzitutto un metodo statico del tipo:

private static boolean booleanCasuale(double probabilitaTrue)

Questo metodo dovrebbe quindi restituire un valore casuale true o false dove però la probabilità del true è indicata dal parametro probabilitaTrue che riceve la percentuale come valore compreso tra 0.0 e 100.0 (entrambi inclusi).

Il codice completo del programma di prova è il seguente:

public class ProvaMonetaTruccata {
    public static void main(String[] args) {
        int nTot = 150;
        int nTesta = 0;
        int nCroce = 0;

        for (int i = 0; i < nTot; i++) {
            if (booleanCasuale(30)) {
                System.out.print("T ");
                nTesta++;
            } else {
                System.out.print("C ");
                nCroce++;
            }
        }

        System.out.println();
        System.out.format("Testa: %4d (%.2f%%)%n", nTesta, nTesta * 100.0 / nTot);
        System.out.format("Croce: %4d (%.2f%%)%n", nCroce, nCroce * 100.0 / nTot);
    }

    private static boolean booleanCasuale(double probabilitaTrue) {
        if (probabilitaTrue < 0 || probabilitaTrue > 100) {
            throw new IllegalArgumentException("Parametro probabilitaTrue non valido: " + probabilitaTrue);
        }

        double v = Math.random() * 100;   // Valore tra 0 (incluso) e 100 (escluso)
        return v < probabilitaTrue;
    }
}

Un risultato di esempio (varia ovviamente da prova a prova) è questo:

T C T T C C T C C C T T T C C C C C C C C T C T T T T T C C T C C C C C T C C T T C T C C C C C T C C C T C C T C C C C C C C T C T C C C C C T C T T T C C C C C C C T C C C T T C C C C C C C C C C T C T T C C T C C T T C C T T C C C T T C T C C C C T C C C C T C T C T C C C C C C T C C C C C C T T
Testa:   48 (32,00%)
Croce:  102 (68,00%)

Il dado truccato

Il caso del dado “truccato” è solamente un pochino più complesso rispetto alla moneta “truccata” ma il concetto è molto similare. Con la moneta ci sono solo 2 valori possibili come risultato, quindi è abbastanza facile “partizionare” un intervallo di valori casuali in due gruppi. Nel dado invece ci sono 6 valori possibili e per ciascuno di essi si potrebbe volere una probabilità differente. Prima di vedere del codice, valutiamo quindi quali sono gli approcci che si possono utilizzare.

Come prima idea si potrebbe pensare di avere un array di 6 elementi che contengono le percentuali da usare come “parametri” per il lancio del dado. Questa soluzione però non è buonissima perché bisogna considerare che la somma delle percentuali dovrebbe sempre dare 100. Se i valori fossero di tipo int, non sarebbe possibile realizzare certe combinazioni delle percentuali, ad esempio non si potrebbe fare il dado “normale” (poiché 100/6 dà 16,6 che non è intero). Se invece i valori fossero dei double, a causa degli errori di rappresentazione in floating-point non è detto che la somma dia esattamente 100.

Ci sono comunque altre soluzioni possibili. Supponiamo innanzitutto, come base di esempio, di volere che il valore 2 abbia probabilità tripla e il valore 5 abbia probabilità quadrupla (rispetto agli altri, chiaramente).

Soluzione 1: array con valori ripetuti

Una soluzione è avere un array in cui certi valori sono ripetuti più volte, così da avere maggiore probabilità di essere estratti. Ad esempio:

int[] valori = { 1, 2, 2, 2, 3, 4, 5, 5, 5, 5, 6 };

Ci sono vantaggi e svantaggi. Il grosso vantaggio è che la estrazione è facilissima, in quanto è sufficiente estrarre un “indice” casuale ed usarlo per accedere ad un elemento. Gli svantaggi sono invece almeno due: essendoci un numero “discreto” di valori è più difficile (ma non impossibile) ottenere proporzioni in decimale (es. 2,5), perché bisogna rifattorizzare il numero degli altri elementi per ottenere quella proporzione. Il secondo svantaggio è che occupa ovviamente più spazio in memoria, dovendo replicare più volte un certo valore.

Questa soluzione comunque va bene in molti casi semplici (come l’esempio fatto sopra) e specialmente quando è accettabile dover “cablare” questo array all’interno del sorgente.

Soluzione 2: array con gli “scaglioni”

Un’altra soluzione è partire con un array che contiene dei “pesi” e calcolare un array che contiene dei valori che rappresentano gli “scaglioni” che serviranno poi per verificare in quale intervallo “cade” un certo valore estratto casualmente. Quindi ad esempio:

pesi = { 1, 3, 1, 1, 4, 1 }
      ↓
scaglioni = { 1, 4, 5, 6, 10, 11 }

Poi per “lanciare” il dado si estrae un valore casuale compreso tra 0 incluso e l’ultimo valore in scaglioni escluso (11 nell’esempio). Se ad esempio venisse estratto il valore 7, si va a cercare nell’array degli scaglioni il primo valore x per cui 7 < x che nell’esempio è il 10 corrispondente al valore 5 del dado.

Con l’esempio appena fatto, la correlazione tra gli intervalli e i valori del dado è la seguente:

  • se il valore n estratto è 0 ≤ n < 1 , il valore del dado è 1
  • se il valore n estratto è 1 ≤ n < 4 , il valore del dado è 2
  • se il valore n estratto è 4 ≤ n < 5 , il valore del dado è 3
  • se il valore n estratto è 5 ≤ n < 6 , il valore del dado è 4
  • se il valore n estratto è 6 ≤ n < 10 , il valore del dado è 5
  • se il valore n estratto è 10 ≤ n < 11 , il valore del dado è 6

Come si può quindi dedurre, il valore 2 ha probabilità tripla perché il suo intervallo di valori è 1 ≤ n < 4 mentre il valore 5 ha probabilità quadrupla perché il suo intervallo è 6 ≤ n < 10.

Anche in questa soluzione ci sono vantaggi e svantaggi. Un vantaggio è che non occupa particolare memoria in più. Un altro ottimo vantaggio è che funziona benissimo e facilmente anche con pesi decimali come 0,8 o 3,5. Lo svantaggio è che richiede un minimo di “logica” per generare l’array degli scaglioni e poi per andare a cercare l’intervallo giusto per il valore estratto.

Esempio completo

La soluzione 2 indicata sopra comunque è quella che ho scelto per l’esempio completo riportato di seguito. Per realizzare la logica descritta è bene “incapsularla” in una apposita classe. Ed è appunto questo l’obiettivo della classe DadoTruccabile.

Il costruttore di DadoTruccabile riceve l’array con i “pesi” (che devono essere esattamente 6 valori) e genera l’array con gli scaglioni. Il metodo lancia si occupa di estrarre un numero casuale e di cercare l’intervallo appropriato all’interno dell’array.

public class DadoTruccabile {
    private final double[] scaglioni;

    public DadoTruccabile(double[] pesiValori) {
        if (pesiValori.length != 6) {
            throw new IllegalArgumentException("Lunghezza array pesiValori non valida: " + pesiValori.length);
        }

        scaglioni = new double[6];
        double somma = 0;

        for (int i = 0; i < scaglioni.length; i++) {
            somma += pesiValori[i];
            scaglioni[i] = somma;
        }
    }

    public int lancia() {
        double v = Math.random() * scaglioni[scaglioni.length-1];
        int i;

        for (i = 0; i < scaglioni.length-1; i++) {
            if (v < scaglioni[i]) {
                break;
            }
        }

        return i+1;
    }
}
public class ProvaDadoTruccabile {
    public static void main(String[] args) {
        DadoTruccabile dado = new DadoTruccabile(new double[] { 1, 3, 1, 1, 4, 1 });

        int nTot = 150;
        int[] nValori = new int[6];

        for (int n = 0; n < nTot; n++) {
            int v = dado.lancia();
            nValori[v-1]++;
            System.out.print(v);
            System.out.print(" ");
        }

        System.out.println();

        for (int i = 0; i < nValori.length; i++) {
            System.out.format("Valore %d: %4d (%.2f%%)%n", i+1, nValori[i], nValori[i] * 100.0 / nTot);
        }
    }
}

Un risultato di esempio (varia ovviamente da prova a prova) è questo:

5 2 1 2 2 3 2 2 6 2 6 1 1 6 2 2 2 5 4 3 5 2 5 5 4 5 2 5 5 6 5 1 5 1 1 6 2 6 2 5 5 2 2 5 5 5 3 2 5 6 5 2 5 5 3 2 5 1 5 5 2 1 5 5 5 2 4 2 5 5 5 5 2 1 5 6 4 5 4 5 4 4 2 1 5 3 5 1 5 2 2 2 3 5 2 5 5 5 2 6 3 2 5 2 2 5 5 5 2 2 4 4 2 5 5 3 5 2 5 2 2 2 1 2 5 2 2 2 5 2 2 4 5 2 2 3 5 5 2 3 3 5 5 5 4 5 5 2 2 3
Valore 1:   12 (8,00%)
Valore 2:   49 (32,67%)
Valore 3:   12 (8,00%)
Valore 4:   11 (7,33%)
Valore 5:   57 (38,00%)
Valore 6:    9 (6,00%)

Conclusioni

Prima di concludere è bene chiarire una cosa: i risultati dei due esempi evidenziano delle percentuali reali che si discostano abbastanza dalle probabilità “teoriche” impostate nei sorgenti. Questo accade perché il numero totale di elementi estratti è relativamente molto basso (150 in entrambi gli esempi). Per ottenere risultati più significativi è necessario estrarre un numero molto maggiore di valori, basta ad esempio provare a mettere nTot = 5000 per verificarlo.

Con questo articolo e con i due esempi concreti spero di aver fornito gli spunti giusti per valutare meglio cosa fare quando è necessario estrarre dei valori casuali che abbiano una certa probabilità specifica di uscita.