Gli algoritmi genetici sono algoritmi che emulano l'evoluzione di una popolazione in cui viga la legge di sopravvivenza del più adatto. In questo articolo presenterò brevemente come si implementa in linea teorica (e pratica) un algoritmo genetico.

Obiettivo

Gli algoritmi genetici vengono usati sostanzialmente per risolvere problemi di massimizzazione. Vengono adottati a favore di altri metodi, ad esempio, perchè il problema è difficilmente formalizzabile, o perchè non esistono basi teoriche forti che supportino la scrittura di un codice adeguato. Una volta posto il problema, si vuole far evolvere una popolazione di soluzioni possibili fino a che non se ne trovi una ottima, che non necessariamente è la soluzione esatta (ma piuttosto quella meno sbagliata). Per esemplificare i passaggi che andrò ad illustrare, cercheremo di risolvere questo problema: Si hanno 10 variabili, a1, a2, ... a10. Tutte devono contenere valori distinti e compresi tra 1 e 20. Si vuole trovare la combinazione ottima di numeri tale sia massima l'espressione: fitness.JPG In questo caso siamo favoriti dalla formulazione del problema perchè, come spiegherò tra breve, disponiamo già della funzione di fitness. Tuttavia attaccare questo problema con i metodi tradizionali non sarebbe possibile: andando di brute force si sprecherebbe più tempo (sebbene gli algoritmi gentici siano una specie di brute force migliorato); usando strumenti matematici quali derivate parziali, si potrebbe formalizzare il problema, ma se la richiesta cambiasse anche solo di poco si dovrebbero rifare tutti i calcoli e riscrivere di conseguenza il codice.

Inizializzazione

Per iniziare, dobbiamo disporre di un insieme di soluzioni possibili, ossia dei vettori casuali di 10 numeri compresi tra 1 e 20. Non è detto che queste soluzioni siano le migliori per il problema, anzi, è altamente improbabile che lo siano poiché sono per ipotesi casuali. Esse costituiscono il cosiddetto genetic pool, o, più semplicemente, la popolazione della cui evoluzione ci occuperemo. Il numero di soluzioni possibili presenti nel pool iniziale è arbitrario e definisce uno dei 4/5 parametri importanti dell'algoritmo: più individui favoriscono una maggiore varietà, ma al contempo incrementano il tempo necessario per passare alla generazione successiva (poiché ci sono più informazioni da processare). Cominciamo con lo scrivere un codice che produca le nostre sequenze di numeri:

Module Module1

    'In questa classe scriveremo tutto il codice dell'algoritmo genetico
    Public Class GeneticEngine
        Private _Population As List(Of Int32())

        'La popolazione delle soluzioni. Ogni soluzione è, come avevamo detto,
        'una serie di 10 numeri, ossia un vettore di interi
        Public ReadOnly Property Population() As List(Of Int32())
            Get
                Return _Population
            End Get
        End Property

        Public Sub New()
            _Population = New List(Of Int32())
        End Sub

        'Crea la popolazione iniziale con numeri casuali
        Public Sub CreatePopulation(ByVal Size As Int32)
            Me.Population.Clear()

            'Il parametro del costruttore di Random è un numero intero detto
            'Seed (seme) che influenza la generazione di numeri pseudocasuali.
            'Usando il numero di millisecondi dell'ora corrente, aggiungiamo un
            'ulteriore fattore di variabilità all'algoritmo.
            Dim Rnd As New Random(Date.Now.Millisecond)
            Dim Numbers As New List(Of Int32)

            For I As Int32 = 1 To Size
                'Nuovo vettore, possibile soluzione
                Dim Vector(9) As Int32

                'Aggiunge i numeri da 1 a 20 a una lista
                Numbers.Clear()
                For J As Int32 = 1 To 20
                    Numbers.Add(J)
                Next
                'Quindi ne estrae 10. Dopo ogni estrazione, cancella il numero
                'estratto dalla lista, in modo che non si ripeta. Il problema
                'specificava infatti che i valori dovessero essere distinti
                For J As Int32 = 0 To 9
                    Vector(J) = Numbers(Rnd.Next(0, Numbers.Count))
                    Numbers.Remove(Vector(J))
                Next
                'Aggiunge il vettore alla popolazione
                Me.Population.Add(Vector)
            Next
        End Sub
    End Class

    Sub Main()
        Dim Engine As New GeneticEngine
        Engine.CreatePopulation(20)

        'Crea una popolazione di 20 individui e scrive a schermo i
        'vettori casuali generati
        For Each Vector As Int32() In Engine.Population
            For I As Int32 = 0 To Vector.Length - 1
                Console.Write(Vector(I) & " ")
            Next
            Console.WriteLine()
        Next

        Console.ReadKey()
    End Sub

End Module
Selezione

Una volta generato il pool genetico iniziale, occore dare una spinta all'algoritmo verso l'evoluzione. Con questo termine intendiamo il miglioramento della popolazione attraverso il passaggio in molte generazioni successive. Ogni generazione ha una determinata popolazione, che si è sviluppata dalla precedente mediante selezione. Come la scienza insegna, in una popolazione ci sono individui più o meno adatti alla riproduzione. Quelli più adatti in genere vantano caratteristiche migliori e perciò hanno più probabilità di accoppiarsi. Nella scrittura dell'algoritmo occorre quindi definire una funzione che ci dica quanto buona è una soluzione. In base a questo potremmo eseguire altri calcoli sulle probabilità di sopravvivenza. Come avevo accennato precedentemente, nell'affrontare questo problema siamo particolarmente fortunati, poiché abbiamo già la funzione da massimizzare e non ci resta altro che tradurla in codice. Dato che si tratta di semplici somme e prodotti, non è nulla di complicato. Questa funzione si chiama generalmente fitness:

Module Module1

    Public Class GeneticEngine
        Private _Population As List(Of Int32())

        Public ReadOnly Property Population() As List(Of Int32())
            Get
                Return _Population
            End Get
        End Property

        Public Sub New()
            _Population = New List(Of Int32())
        End Sub

        'La somma è fatta con gli operatori di aggregazione di LINQ, mentre il
        'prodotto è una banale trascrizione a mano degli unici elementi di
        'posto primo (ricordate che l'elemento di indice 1 ha posto 2, eccetera...).
        'Tuttavia, se ci sono elementi ripetuti, il vettore è da scartare, e la
        'sua fitness sarà nulla. Nonostante la nostra accortezza nel controllare
        'questa condizione al momento dell'inizializzazione, vedremo che ci sono 
        'fenomeni quali la mutazione e il crossover che possono scombinare l'array
        Public Function EvaluateFitness(ByVal Vector As Int32()) As Double
            Dim Result As Double = Vector.Sum(Function(I As Int32) ((I Mod 2) * 2 - 1) * I) + Vector(1) * Vector(2) * Vector(4) * Vector(6) / (2 * 3 * 5 * 7)
            If Vector.Distinct().Count < 10 Then
                Result = 0
            End If
            Return Result
        End Function

        Public Sub CreatePopulation(ByVal Size As Int32)
            Me.Population.Clear()

            Dim Rnd As New Random(Date.Now.Millisecond)
            Dim Numbers As New List(Of Int32)

            For I As Int32 = 1 To Size
                Dim Vector(9) As Int32

                Numbers.Clear()
                For J As Int32 = 1 To 20
                    Numbers.Add(J)
                Next
                For J As Int32 = 0 To 9
                    Vector(J) = Numbers(Rnd.Next(0, Numbers.Count))
                    Numbers.Remove(Vector(J))
                Next
                Me.Population.Add(Vector)
            Next
        End Sub
    End Class

    Sub Main()
        Dim Engine As New GeneticEngine
        Engine.CreatePopulation(20)

        For Each Vector As Int32() In Engine.Population
            For I As Int32 = 0 To Vector.Length - 1
                Console.Write("{0:00} ", Vector(I))
            Next
            Console.WriteLine("  >  Fitness = {0}", Engine.EvaluateFitness(Vector))
        Next

        Console.ReadKey()
    End Sub
    
End Module

Per capire meglio la funzione Sum() vi consiglio di leggere questo articolo. L'espressione ((I Mod 2) * 2 - 1) è un modo computazionalmente medo dispendioso di scrivere (-1)^(i+1): potete verificarlo facilmente facendo due calcoli. Per il resto, ora abbiamo tutto il nostro pool genetico, completo di fitness per ogni individuo. Noi vogliamo che solo le soluzioni con fitness più alta possano accoppiarsi. Tuttavia, esistono molte tecniche diverse per scegliere quale soluzione si possa accoppiare, tutte basate sulla probabilità/casualità e sulla fitness.

Selezione a Roulette (roulette-wheel selection)

In questo tipo di selezione, l'individuo con fitness maggiore ha maggiori probabilità di accoppiarsi, ma non è certo che questo succeda. Per calcolare questa probabilità si prende la sua fitness e la si divide per la fitness totale di tutta la popolazione (ossia la somma di tutte le fitness calcolate). Il nome deriva dal fatto che, se immaginassimo di avere una lunga striscia che rappresenta la superficie della corona circolare di una roulette e di suddividere questa striscia in porzioni proporzionali alla fitness di ogni individuo, allora lanciando la pallina e facendo girare la roulette, l'individuo con la fetta più grande avrebbe maggiori possibilità che la pallina si fermi sulla sua parte di striscia: Fitness_proportionate_selection_example.png La relazione tra f e p, tuttavia, non è proprio proporzionale, ma piuttosto asintotica. Infatti, qualora f tendesse a infinito, p tenderebbe a 1, rendendo certo l'evento "accoppiamento" per la soluzione data. Ecco una semplice implementazione:

'Amount è il numero di candidati da selezionare. Anche questo
'è un parametro che caratterizza l'algoritmo.
Public Function SelectBoidsRoulette(ByVal Amount As Int32) As List(Of Int32())
    'Calcola la fitness per ogni soluzione. Fitness è un IEnumerable(Of Double)
    'che contiene tanti elementi quante sono le soluzioni. L'i-esimo elemento
    'indica la fitness dell'i-esima soluzione
    Dim Fitnesses = From Vector As Int32() In Me.Population _
        Select EvaluateFitness(Vector)
    'Calcola la fitness totale sommando tutti i valori contenuti nella
    'collezione Fitnesses appena creata
    Dim TotalFitness As Double = Fitnesses.Sum()
    Dim Result As New List(Of Int32())
    Dim Rnd As New Random(Date.Now.Millisecond)
    Dim Temp As Double = 0
    Dim Value As Double

    Do
        'Estrae un valore a caso tra 0 e 1
        Value = Rnd.NextDouble()

        Temp = 0
        'Ora, per ogni soluzione, calcola la sua probabilità di essere
        'scelta. Questa è un numero tra 0 e 1, e la somma di tutte le
        'probabilità di ogni evento possibile è ovviamente 1.
        'Costruiamo quindi tanti piccoli intervallini che corrispondono alle
        'parti in cui era divisa l'ipotetica roulette. Ogni intervallino ha
        'ampiezza pari alla probabilità del corrispondente individuo di
        'essere scelto, ed è posto subito dopo il suo precedente.
        'In questo modo, se avessimo 5 individui con fitness rispettivamente 1, 2, 3,
        '4 e 5, avremo che (posto che 1+2+3+4+5=15):
        ' - il primo intervallino è [0, 1/15);
        ' - il secondo è [1/15, 1/15 + 2/15) = [1/15, 3/15);
        ' - il terzo è [3/15, 3/15 + 3/15) = [3/15, 6/15);
        ' - il quarto è [6/15, 10/15);
        ' - il quinto è [10/15, 1).
        'Scegliendo un numero casuale tra 0 e 1 possiamo vedere in quale di questi
        'intervallini esso ricada. Sceglieremo come candidato l'individuo nel
        'cui intervallo è stato pescato il numero casuale.
        'Come si nota, l'ultimo possiede la fascia più ampia, ma non per questo
        'gli è garantito di essere estratto.
        For I As Int32 = 0 To Fitnesses.Count() - 1
            If (Value >= Temp) And (Value < Temp + Fitnesses(I) / TotalFitness) Then
                'Non aggiunge la soluzione se è già stata scelta. In questo caso
                'sarà eseguita un'altra iterazione grazie al Do Loop
                If Not Result.Contains(Me.Population(I)) Then
                    Result.Add(Me.Population(I))
                End If
            End If
            Temp += Fitnesses(I) / TotalFitness
        Next
        
    Loop Until Result.Count = Amount

    Fitnesses = Nothing
    Rnd = Nothing

    Return Result
End Function