Obiettivi di apprendimento

Dopo questa guida saprai costruire da zero un modello ad agenti in Python partendo da NetLogo: dalla struttura del codice alla gestione degli agenti, dal movimento su griglia al contagio, fino alla raccolta dei dati, alla curva SIR e alla GIF animata del mondo.

Vuoi vedere subito il codice completo? 📄 Vai al codice completo ↓
00
Cos’è un modello ad agenti

Un modello ad agenti (ABM, Agent-Based Model) è una simulazione in cui il comportamento globale emerge dalle interazioni locali di tante entità autonome dette agenti. Ogni agente ha uno stato interno, percepisce l’ambiente circostante e agisce secondo regole semplici. Da queste interazioni locali emergono pattern complessi a livello di sistema — pattern che non si potrebbero prevedere guardando solo le regole dei singoli agenti.

Ogni ABM è composto da quattro elementi fondamentali:

Agenti

Le entità attive della simulazione. Ogni agente ha uno stato (es. sano, infetto, guarito) e un insieme di comportamenti che esegue ad ogni passo.

Ambiente

Lo spazio in cui gli agenti esistono e si muovono. Può essere una griglia discreta (celle intere), uno spazio continuo 2D, una rete di relazioni, o anche solo un insieme senza geometria.

Regole

Cosa fa ogni agente ad ogni tick: come si muove, quando interagisce con altri, come cambia stato. Le regole sono locali — ogni agente vede solo ciò che è vicino a lui.

Emergenza

Il pattern globale che nasce dalle interazioni locali. Non è scritto da nessuna parte: emerge dalla simulazione. È ciò che si vuole studiare o misurare.

Cosa si modella prima: gli agenti o l’ambiente?

La risposta è che si parte sempre dal fenomeno che si vuole studiare. Il processo di design segue questo ordine:

✦ Fenomeno
Cosa vuoi osservare emergere?
Agenti
Chi agisce? Che stato ha?
Ambiente
In che spazio si muovono?
Regole
Movimento, interazione, aggiornamento
Perché gli agenti prima dell’ambiente Gli agenti sono le unità attive: decidono, interagiscono, cambiano stato. L’ambiente è il palcoscenico — importante, ma passivo. Definire prima gli agenti (che stato hanno? che decisioni prendono?) aiuta a capire di che ambiente hanno bisogno: una griglia? uno spazio continuo? nessuna geometria? I due elementi si co-definiscono, ma il punto di partenza è sempre l’agente.

NetLogo è l’ambiente più usato per costruire modelli ad agenti in ambito didattico e di ricerca. Ha un linguaggio semplice e un’interfaccia grafica integrata. Python è il linguaggio di riferimento per la ricerca computazionale moderna: è molto più versatile, si integra con librerie di analisi dati e machine learning, ed è quello che si trova nella maggior parte dei progetti di ricerca.

Questa guida mostra come costruire un modello ad agenti in Python partendo da NetLogo usando come esempio il modello SIR discreto. Ogni concetto NetLogo viene affiancato al suo equivalente Python, spiegando non solo come si scrive ma perché.

01
Il modello SIR

Il modello SIR è il modello epidemiologico più classico. Ogni agente è una persona che si trova in uno di tre stati: Suscettibile (può ammalarsi), Infetto (può contagiare), Rimosso (guarito e immune). Gli agenti si muovono su una griglia discreta; quando un infetto è nella stessa cella o in una cella adiacente a un suscettibile, lo contagia con probabilità β. Ad ogni tick, ogni infetto guarisce con probabilità γ.

Applicando il processo di design visto sopra:

DomandaRisposta nel modello SIR discreto
FenomenoCome si diffonde un’epidemia? Emerge una curva a campana per i casi attivi?
AgentiPersone con stato S/I/R e posizione intera (cella) sulla griglia
AmbienteGriglia discreta di celle intere, mondo da −16 a +16 su entrambi gli assi, bordi duri (no wrap)
RegoleMovimento di 1 cella in direzione casuale (N/S/E/W); I contagia S a distanza di Chebyshev ≤ 1 con probabilità β; ogni tick I guarisce con probabilità γ
EmergenzaCurva epidemica: S scende, I sale poi scende, R sale. L’epidemia si esaurisce da sola.
S Suscettibile I Infetto R Rimosso random() < β prob. × vicinanza random() < γ probabilistico

Regole di transizione degli stati:

TransizioneCondizioneNota
S → Iun agente I è a distanza di Chebyshev ≤ 1 e random() < βProbabilistica — non sempre avviene
I → Rrandom() < γ ad ogni tickProbabilistica — ogni tick c’è una chance di guarire
R → RI rimossi sono immuni, non cambiano più stato
02
Struttura del codice

In NetLogo tutto il codice sta in un unico file .nlogo. In Python costruiamo il modello in un unico file SIR_model_discreto.py. La struttura è identica: setup, ciclo principale, procedure. Il SIR discreto prevede tre procedure per tick: muovi-agenti, contagio e guarigione.

NetLogo — struttura
Python — struttura equivalente
to setup
  clear-all
  reset-ticks
  create-turtles population [...]
  ask n-of initial-infected turtles [
    set state "I" ]
end

to go
  if days-passed >= days [ stop ]
  move
  infect
  recover
  set days-passed days-passed + 1
  tick
end

to move ... end
to infect ... end
to recover ... end
def crea_agenti(cfg):
    # equivale a crea-agenti
    ...

def muovi_agenti(agents, cfg): ...
def contagio(agents, cfg): ...
def guarigione(agents, cfg): ...

def run_simulation(cfg):
    # equivale a go
    agents = crea_agenti(cfg)
    for day in range(cfg.days):
        muovi_agenti(agents, cfg)
        contagio(agents, cfg)
        guarigione(agents, cfg)
    return history, snapshots

if __name__ == "__main__":
    cfg = config()
    history, snapshots = run_simulation(cfg)

I parametri del modello — da slider a config:

In NetLogo i parametri si impostano tramite slider nell’interfaccia grafica. In Python li raccogliamo in una @dataclass chiamata config. Questo blocco è il primo che scrivi: definisce tutto ciò che è configurabile nel modello.

NetLogo — slider
Python — @dataclass config
population          100  ; slider (1–100)
; mondo: -16 a +16 (world settings)
initial-infected     50  ; inputbox — N agenti I
beta               1.0  ; slider — probabilità contagio
gamma             0.19  ; slider — probabilità guarigione
days               100  ; inputbox — durata simulazione
@dataclass
class config:
    population:       int   = 100
    grid_size:        int   = 16
    initial_infected: int   = 10   # resto parte S
    beta:             float = 0.9
    gamma:            float = 0.05
    days:             int   = 100

    def __post_init__(self):
        if self.initial_infected > self.population:
            raise ValueError("infected > population")
Perché Python puro invece di Mesa o AgentPy? Esistono librerie Python specifiche per i modelli ad agenti (Mesa, AgentPy). Questa guida usa Python puro con NumPy perché è più vicino a come NetLogo funziona internamente: agenti come oggetti, un ciclo esplicito, funzioni che corrispondono alle procedure. Una volta compresa questa base, passare a Mesa diventa molto più naturale.
03
L’agente — da turtles-own a @dataclass

In NetLogo ogni agente è una turtle con attributi propri dichiarati in turtles-own. In Python costruiamo un agente come una dataclass. L’agente SIR discreto ha tre attributi: lo stato epidemico e la posizione sulla griglia (pos_x, pos_y), che sono interi perché rappresentano celle, non punti continui. Non c’è velocità (il passo è sempre 1 cella) né timer (la guarigione è probabilistica).

NetLogo
Python
; attributi automatici di ogni turtle:
; xcor, ycor (interi su patch)
; + quelli che dichiariamo:

turtles-own [
  state   ; "S", "I" o "R"
]

; xcor e ycor sono già interi
; (coordinate di patch)
@dataclass
class agent:
    state: str = 'S'  # S, I o R
    pos_x: int = 0    # ← xcor (cella, non float)
    pos_y: int = 0    # ← ycor (cella, non float)

# pos_x e pos_y partono da 0
# come default neutro — vengono
# assegnati davvero in crea_agenti

Creare tutti gli agenti:

NetLogo — crea-agenti
Python — crea_agenti()
to crea-agenti
  create-turtles population [
    setxy random-pxcor random-pycor
    set state "S"
    set color blue
  ]
  ask n-of initial-infected turtles [
    set state "I"
    set color red
  ]
end
def crea_agenti(cfg):
    agents = []
    for i in range(cfg.population):
        a = agent()  # agente vuoto con default
        a.pos_x = np.random.randint(
            -cfg.grid_size, cfg.grid_size + 1)
        a.pos_y = np.random.randint(
            -cfg.grid_size, cfg.grid_size + 1)
        if i < cfg.initial_infected:
            a.state = 'I'
        else:
            a.state = 'S'  # tutti gli altri partono S
        agents.append(a)
    return agents
Come si legge a parole
Riga per riga
  1. Parti con una lista vuota agents = [].
  2. Per ogni agente da 0 a population: crea un agente vuoto con a = agent().
  3. Assegna una posizione intera casuale sulla griglia con randint.
  4. Assegna lo stato in base all’indice: i primi N → I, tutti gli altri → S.
  5. Aggiungi l’agente alla lista con append. Alla fine restituisci la lista.
Perché a = agent() e poi a.pos_x = ...

La posizione dipende da cfg.grid_size, quindi non può essere un valore di default nella classe. Si crea prima l’agente “vuoto” con i default, poi si assegnano i campi che richiedono calcoli.

cfg.population e non solo population
Dentro la funzione Python non sa cosa è population da solo. Il prefisso cfg. dice “cerca population dentro l’oggetto cfg”, che è l’istanza della classe config passata in ingresso.

Perché @dataclass? Il decoratore @dataclass genera automaticamente il metodo __init__, così non devi scrivere self.state = state ecc. per ogni attributo. I default (state = 'S', pos_x = 0) servono come placeholder — i valori reali vengono assegnati subito dopo in crea_agenti.
04
Spazio e movimento — griglia discreta

Il modello usa una griglia discreta: ogni agente occupa una cella intera identificata da due coordinate intere (pos_x, pos_y). Il mondo va da −16 a +16 su entrambi gli assi, con bordi duri: un agente che cerca di uscire dalla griglia rimane fermo al bordo (non c’è wrap-around).

Il movimento è di 1 cella per tick in una delle quattro direzioni cardinali (N, S, E, W). Non serve trigonometria: le direzioni sono predefinite come coppie (dx, dy).

NetLogoPythonSignificato
xcor, ycor (int su patch)pos_x: int, pos_y: intCoordinate intere della cella
random-pxcornp.random.randint(-16, 17)Posizione casuale intera
rt one-of [0 90 180 270]DIRECTIONS[np.random.randint(0, 4)]Direzione casuale tra 4
fd 1pos_x += dx; pos_y += dySpostamento di 1 cella
if patch-ahead 1 != nobodynp.clip(valore, -16, 16)Bordi duri — nessun wrap

La costante DIRECTIONS:

Le quattro direzioni cardinali sono rappresentate come una lista di tuple (dx, dy). È in MAIUSCOLO perché è una costante: il suo valore non cambia mai durante la simulazione.

NetLogo — muovi-agenti
Python — muovi_agenti()
to move
  ask turtles [
    let new-x xcor
    let new-y ycor
    let dir random 4
    if dir = 0 [ set new-y ycor + 1 ]  ; Nord
    if dir = 1 [ set new-y ycor - 1 ]  ; Sud
    if dir = 2 [ set new-x xcor + 1 ]  ; Est
    if dir = 3 [ set new-x xcor - 1 ]  ; Ovest
    ; clamp ai bordi del mondo
    if new-x > max-pxcor [ set new-x max-pxcor ]
    if new-x < min-pxcor [ set new-x min-pxcor ]
    if new-y > max-pycor [ set new-y max-pycor ]
    if new-y < min-pycor [ set new-y min-pycor ]
    setxy new-x new-y
  ]
end
# N, S, E, W — costante, non cambia mai
DIRECTIONS = [(0, 1), (0, -1), (1, 0), (-1, 0)]

def muovi_agenti(agents, cfg):
    for a in agents:
        # pesca una tupla casuale e spacchetta
        dx, dy = DIRECTIONS[np.random.randint(0, 4)]

        # sposta e blocca al bordo con np.clip
        a.pos_x = np.clip(
            a.pos_x + dx, -cfg.grid_size, cfg.grid_size)
        a.pos_y = np.clip(
            a.pos_y + dy, -cfg.grid_size, cfg.grid_size)
Come si legge a parole
Due costrutti chiave

dx, dy = DIRECTIONS[np.random.randint(0, 4)]
randint(0, 4) pesca un numero tra 0 e 3 → seleziona una delle 4 tuple → la virgola spacchetta la tupla in due variabili. Se esce (1, 0), allora dx=1 e dy=0 (Est).

np.clip(valore, min, max)
Equivale a “tieni il valore nell’intervallo [min, max]”. Se l’agente sarebbe a 17, np.clip(17, -16, 16) restituisce 16 — il bordo. Sostituisce il pattern max(min(), min(max(), ...)) in modo molto più leggibile.

Continuo vs discreto

Nel continuo si usa la trigonometria: angolo casuale → dx = velocità × cos(angolo), dy = velocità × sin(angolo). Serve perché la direzione è qualsiasi valore tra 0 e 2π.

Nel discreto le direzioni sono solo 4 e il passo è sempre 1 — nessuna trigonometria, nessuna velocità da memorizzare, nessun campo angolo nell’agente. Più semplice e naturale per una griglia.

Distanza di Chebyshev — naturale per le griglie

Definizione d = max(|dx|, |dy|) — il massimo tra la differenza assoluta sulle x e quella sulle y.
d ≤ 1 Cattura la cella stessa (d=0) + le 8 celle vicine (4 cardinali + 4 diagonali) = vicinato di Moore. È ciò che si usa nel contagio.
Perché non euclidea Su griglia la diagonale è a distanza √2 ≈ 1.41 in euclidea, ma è una cella adiacente. Con Chebyshev è d=1, il che è corretto: si raggiunge in un passo.
05
Il contagio — coppie, Chebyshev e continue

Questa è la regola di interazione del SIR. Per ogni coppia di agenti vicini (uno I e uno S), si applica la probabilità β di contagio. La prossimità si misura con la distanza di Chebyshev: d = max(|dx|, |dy|). Se d ≤ 1, i due agenti sono nella stessa cella o in celle adiacenti.

NetLogo — contagio
Python — contagio()
to infect
  ask turtles with [state = "I"] [
    let x1 xcor
    let y1 ycor
    ; distanza di Chebyshev esplicita
    ask other turtles with [state = "S"] [
      let d max list
            (abs (xcor - x1))
            (abs (ycor - y1))
      if d <= 1 [
        if random-float 1 < beta [
          set state "I"
          set color red
        ]
      ]
    ]
  ]
end
def contagio(agents, cfg):
    for i in range(len(agents)):
        for j in range(i + 1, len(agents)):
            a, b = agents[i], agents[j]
            d = max(abs(a.pos_x - b.pos_x),
                    abs(a.pos_y - b.pos_y))  # Chebyshev
            if d > 1:
                continue  # troppo lontani, prossima coppia
            if a.state == 'I' and b.state == 'S':
                if np.random.rand() < cfg.beta:
                    b.state = 'I'
            elif a.state == 'S' and b.state == 'I':
                if np.random.rand() < cfg.beta:
                    a.state = 'I'
Come si legge a parole
Il doppio for con j > i

Il primo for i scorre tutti gli agenti. Il secondo for j in range(i+1, ...) scorre solo quelli con indice più grande di i. Con 4 agenti le coppie controllate sono: (0,1) (0,2) (0,3) (1,2) (1,3) (2,3) — mai la stessa due volte, mai un agente con se stesso.

continue salta direttamente alla coppia successiva senza eseguire il resto del corpo del for. È un’uscita anticipata: se i due agenti sono lontani, non ha senso controllare lo stato.

Perché le coppie invece di un singolo for

Il contagio è simmetrico: se a contagia b, potrebbe anche essere b a contagiare a. Il doppio for con coppie gestisce entrambi i casi con due elif nella stessa iterazione.

Alternativa: un singolo for a in cui ogni infetto scorre tutti gli altri. Più leggibile ma controlla ogni coppia due volte. Il pattern (i, j > i) è più efficiente.

NetLogoPythonNota
ask other turtles with [state="S"] + Chebyshev esplicitofor i, for j con Chebyshev ≤ 1Vicinato di Moore (8 celle + sé)
with [state = "S"]a.state == 'I' and b.state == 'S'Filtra per stato
random-float 1 < betanp.random.rand() < cfg.betaEvento probabilistico in [0,1)
set state "I"b.state = 'I'Modifica diretta sul campo
Complessità O(N²) La funzione controlla tutte le coppie di agenti — questo è O(N²). Per 50 agenti vanno benissimo. Per modelli con migliaia di agenti esistono strutture dati spaziali (griglia di celle, k-d tree) che accelerano la ricerca dei vicini a O(N).
06
La guarigione — probabilistica con γ

Questa procedura gestisce il cambiamento I → R. È completamente indipendente dalla posizione: ogni tick, ogni agente infetto ha una probabilità γ di guarire. Non serve un timer — la durata dell’infezione emerge statisticamente da γ: un γ basso significa guarigioni rare e infezione lunga, un γ alto guarigioni rapide.

NetLogo — guarigione
Python — guarigione()
to recover
  ask turtles with [state = "I"] [
    ; probabilistico: random-float 1.0
    if random-float 1.0 < gamma [
      set state "R"
      set color green
    ]
  ]
end
def guarigione(agents, cfg):
    for a in agents:
        if a.state == 'I':           # solo gli infetti
            if np.random.rand() < cfg.gamma:  # tira il dado
                a.state = 'R'        # guarisce
Probabilistica vs deterministica Una guarigione deterministica userebbe un timer: dopo N tick l’agente guarisce sempre. Una guarigione probabilistica usa γ: ogni tick c’è una probabilità di guarire. Le due sono equivalenti in media (durata media = 1/γ tick), ma quella probabilistica produce variabilità individuale più realistica. Il modello discreto usa la versione probabilistica — è anche più semplice: nessun campo timer nell’agente.
07
Il ciclo principale — da to go al loop Python

In NetLogo to go è la procedura chiamata ad ogni tick. In Python è un for loop che chiama le tre procedure nell’ordine corretto. Il ciclo raccoglie anche due tipi di dati: i conteggi S/I/R per il grafico e gli snapshot di posizioni e stati per la GIF animata.

NetLogo — to go
Python — run_simulation()
to go
  if days-passed >= days [ stop ]
  move    ; 1. movimento
  infect  ; 2. contagio
  recover ; 3. guarigione
  set days-passed days-passed + 1
  tick
  ; il plot si aggiorna automaticamente
end
def run_simulation(cfg):
    agents = crea_agenti(cfg)
    history   = {'S': [], 'I': [], 'R': []}
    snapshots = []  # lista di liste di tuple

    for day in range(cfg.days):
        muovi_agenti(agents, cfg)   # 1. movimento
        contagio(agents, cfg)       # 2. contagio
        guarigione(agents, cfg)     # 3. guarigione

        s, i, r = conta_stati(agents)  # conta S/I/R
        history['S'].append(s)
        history['I'].append(i)
        history['R'].append(r)

        snap = [(a.pos_x, a.pos_y, a.state)
                for a in agents]    # snapshot del giorno
        snapshots.append(snap)

    return history, snapshots
NetLogoPythonNota
movemuovi_agenti(agents, cfg)Sposta tutti gli agenti
infectcontagio(agents, cfg)Controlla tutte le coppie
recoverguarigione(agents, cfg)Aggiorna stati I→R
plot automaticohistory['S'].append(s)Raccolta manuale dei dati
vista automaticasnapshots.append(snap)Raccolta posizioni per la GIF
tickindice del for loop (day)Contatore automatico
Perché return history, snapshots e non solo history La funzione restituisce due valori insieme come tupla. Chi chiama la funzione li riceve separati: history, snapshots = run_simulation(cfg). Senza return entrambi andrebbero persi — esistono solo dentro la funzione.
08
Dati e visualizzazione — la curva epidemica

In NetLogo il plot delle popolazioni S/I/R si configura nell’interfaccia grafica e si aggiorna automaticamente. In Python raccogliamo i dati nel dizionario history durante la simulazione e poi li plottiamo con Matplotlib. Il risultato atteso è la classica curva epidemica: S decresce, I sale poi scende a campana, R cresce.

NetLogo — plot automatico
Python — plot_risultati()
; configurato nell'interfaccia:
; plot count turtles with [state="S"]
; plot count turtles with [state="I"]
; plot count turtles with [state="R"]
; → si aggiorna ad ogni tick
def plot_risultati(history):
    giorni = range(len(history['S']))
    plt.figure(figsize=(9, 5))
    plt.plot(giorni, history['S'],
             label='Susceptible', color='steelblue')
    plt.plot(giorni, history['I'],
             label='Infected',    color='crimson')
    plt.plot(giorni, history['R'],
             label='Recovered',   color='seagreen')
    plt.xlabel('Giorni')
    plt.ylabel('Numero di agenti')
    plt.title('Modello SIR — spazio discreto')
    plt.legend()
    plt.tight_layout()
    plt.show()
Cosa cambia al variare dei parametri Aumentare beta allarga e anticipa il picco della curva I. Diminuire gamma allunga il periodo infettivo e amplifica l’epidemia. Ridurre grid_size aumenta la densità degli agenti e accelera il contagio perché gli agenti si incontrano più spesso. Questi sono esattamente i parametri che gli epidemiologi variano negli studi di intervento.
09
Animazione GIF — visualizzare il mondo

In NetLogo la vista grafica si aggiorna automaticamente ad ogni tick: le turtle si muovono e cambiano colore in tempo reale. In Python questa visualizzazione non esiste di default — dobbiamo costruirla con matplotlib.animation.FuncAnimation, che crea un’animazione frame per frame e la salva come GIF.

NetLogo — vista automatica
Python — crea_animazione()
; NetLogo aggiorna la vista
; automaticamente ad ogni tick.
; Per colorare gli agenti per stato:
ask turtles [
  if state = "S" [ set color blue  ]
  if state = "I" [ set color red   ]
  if state = "R" [ set color green ]
]
; La griglia (patch) è già visibile.
; Nessun codice aggiuntivo.
STATE_COLORS = {
    'S': 'steelblue',  # ← color blue
    'I': 'crimson',    # ← color red
    'R': 'seagreen'    # ← color green
}

def crea_animazione(snapshots, cfg,
                    filename='SIR_animazione.gif'):
    fig, ax = plt.subplots(figsize=(6, 6))
    # griglia: tick a ±0.5 → agenti al centro della cella
    ticks = [x - 0.5 for x in
             range(-cfg.grid_size, cfg.grid_size + 2)]
    ax.set_xticks(ticks); ax.set_yticks(ticks)
    ax.tick_params(labelbottom=False, labelleft=False)
    ax.grid(True, color='lightgray', linewidth=0.5)
    ax.set_aspect('equal')

    scatter  = ax.scatter([], [], s=50)
    day_text = ax.text(0.02, 0.95, '',
                       transform=ax.transAxes)
    legend_patches = [Patch(color=c, label=s)
                      for s, c in STATE_COLORS.items()]
    ax.legend(handles=legend_patches, loc='upper right')

    def update(frame):
        snap   = snapshots[frame]
        xs     = [p[0] for p in snap]
        ys     = [p[1] for p in snap]
        colors = [STATE_COLORS[p[2]] for p in snap]
        scatter.set_offsets(list(zip(xs, ys)))
        scatter.set_color(colors)
        day_text.set_text(f'Giorno {frame}')
        return scatter, day_text

    anim = animation.FuncAnimation(
        fig, update, frames=len(snapshots),
        interval=100, blit=True)
    anim.save(filename, writer='pillow', fps=10)
    plt.close()

La struttura di snapshots:

snapshots è una lista di liste di tuple: per ogni giorno, una lista con una tupla per agente. Ogni tupla contiene tre valori: (pos_x, pos_y, state).

snapshots = [ [(3, -2, 'S'), (1, 4, 'I'), (-5, 0, 'R'), ...], # giorno 0 → 50 tuple [(4, -2, 'S'), (1, 3, 'I'), (-5, 1, 'R'), ...], # giorno 1 → 50 tuple ... # fino al giorno 99 ] # Come accedere a un elemento specifico: snapshots[0] # lista di tuple del giorno 0 snapshots[0][1] # tupla dell'agente 1 al giorno 0 → (1, 4, 'I') snapshots[0][1][2] # stato dell'agente 1 al giorno 0 → 'I'
Come funziona FuncAnimation
Riga per riga
  1. Apre la finestra con la griglia disegnata: linee a ±0.5 rispetto alle coordinate intere, così ogni agente appare al centro della sua cella.
  2. Crea uno scatter plot vuoto come segnaposto.
  3. FuncAnimation chiama update(frame) una volta per ogni giorno.
  4. update prende lo snapshot del giorno corrente, estrae x, y, colori e aggiorna il grafico.
  5. anim.save(..., writer='pillow') assembla tutti i frame e salva la GIF.
Parametri chiave

frames=len(snapshots) — quanti frame ha la GIF (= quanti giorni).

interval=100 — millisecondi tra un frame e l’altro nel player. Con 100ms si ha 10 fps.

blit=True — ridisegna solo gli oggetti che cambiano (più veloce).

writer='pillow' — usa la libreria Pillow per salvare la GIF. Richiede pip install pillow.

Perché i tick della griglia sono a ±0.5 Le posizioni degli agenti sono interi (0, 1, 2…). Se le linee della griglia fossero anch’esse su valori interi, gli agenti cadrebbero esattamente sugli incroci delle linee invece che al centro delle celle. Spostando i tick di 0.5 (es. la linea tra la cella 0 e la cella 1 va a 0.5), ogni cella diventa un quadrato ben definito e l’agente al suo centro.
10
Codice Python completo

Tutto insieme in un unico file SIR_model_discreto.py. Copia, esegui con python SIR_model_discreto.py e ottieni la curva epidemica e la GIF animata.

import numpy as np import matplotlib.pyplot as plt import matplotlib.animation as animation from matplotlib.patches import Patch from dataclasses import dataclass # ── Config (equivale agli slider di NetLogo) ──────────────────────── @dataclass class config: population: int = 100 grid_size: int = 16 initial_infected: int = 10 # gli altri partono S beta: float = 0.9 gamma: float = 0.05 days: int = 100 def __post_init__(self): if self.initial_infected > self.population: raise ValueError( f"initial_infected ({self.initial_infected}) " f"> population ({self.population})." ) # ── Agente (equivale a turtles-own) ──────────────────────────────── @dataclass class agent: state: str = 'S' # S, I o R pos_x: int = 0 # ← xcor (cella intera) pos_y: int = 0 # ← ycor (cella intera) # ── Setup (equivale a crea-agenti) ───────────────────────────────── def crea_agenti(cfg): agents = [] for i in range(cfg.population): a = agent() a.pos_x = np.random.randint(-cfg.grid_size, cfg.grid_size + 1) a.pos_y = np.random.randint(-cfg.grid_size, cfg.grid_size + 1) if i < cfg.initial_infected: a.state = 'I' else: a.state = 'S' agents.append(a) return agents # ── Movimento discreto (equivale a muovi-agenti) ─────────────────── DIRECTIONS = [(0, 1), (0, -1), (1, 0), (-1, 0)] # N, S, E, W def muovi_agenti(agents, cfg): for a in agents: dx, dy = DIRECTIONS[np.random.randint(0, 4)] a.pos_x = np.clip(a.pos_x + dx, -cfg.grid_size, cfg.grid_size) a.pos_y = np.clip(a.pos_y + dy, -cfg.grid_size, cfg.grid_size) # ── Contagio (equivale a contagio) ───────────────────────────────── def contagio(agents, cfg): for i in range(len(agents)): for j in range(i + 1, len(agents)): a, b = agents[i], agents[j] d = max(abs(a.pos_x - b.pos_x), abs(a.pos_y - b.pos_y)) if d > 1: continue if a.state == 'I' and b.state == 'S': if np.random.rand() < cfg.beta: b.state = 'I' elif a.state == 'S' and b.state == 'I': if np.random.rand() < cfg.beta: a.state = 'I' # ── Guarigione (equivale a guarigione) ───────────────────────────── def guarigione(agents, cfg): for a in agents: if a.state == 'I': if np.random.rand() < cfg.gamma: a.state = 'R' # ── Conteggio stati ───────────────────────────────────────────────── def conta_stati(agents): s = sum(1 for a in agents if a.state == 'S') i = sum(1 for a in agents if a.state == 'I') r = sum(1 for a in agents if a.state == 'R') return s, i, r # ── Ciclo principale (equivale a to go) ──────────────────────────── def run_simulation(cfg): agents = crea_agenti(cfg) history = {'S': [], 'I': [], 'R': []} snapshots = [] for day in range(cfg.days): muovi_agenti(agents, cfg) contagio(agents, cfg) guarigione(agents, cfg) s, i, r = conta_stati(agents) history['S'].append(s) history['I'].append(i) history['R'].append(r) snap = [(a.pos_x, a.pos_y, a.state) for a in agents] snapshots.append(snap) return history, snapshots # ── Visualizzazione curva S/I/R ───────────────────────────────────── def plot_risultati(history): giorni = range(len(history['S'])) plt.figure(figsize=(9, 5)) plt.plot(giorni, history['S'], label='Susceptible', color='steelblue') plt.plot(giorni, history['I'], label='Infected', color='crimson') plt.plot(giorni, history['R'], label='Recovered', color='seagreen') plt.xlabel('Giorni') plt.ylabel('Numero di agenti') plt.title('Modello SIR — spazio discreto') plt.legend() plt.tight_layout() plt.show() # ── Animazione GIF ────────────────────────────────────────────────── STATE_COLORS = {'S': 'steelblue', 'I': 'crimson', 'R': 'seagreen'} def crea_animazione(snapshots, cfg, filename='SIR_animazione.gif'): fig, ax = plt.subplots(figsize=(6, 6)) ax.set_xlim(-cfg.grid_size - 0.5, cfg.grid_size + 0.5) ax.set_ylim(-cfg.grid_size - 0.5, cfg.grid_size + 0.5) ax.set_aspect('equal') ax.set_title('Modello SIR — spazio discreto') ticks = [x - 0.5 for x in range(-cfg.grid_size, cfg.grid_size + 2)] ax.set_xticks(ticks); ax.set_yticks(ticks) ax.tick_params(labelbottom=False, labelleft=False) ax.grid(True, color='lightgray', linewidth=0.5, zorder=0) legend_patches = [Patch(color=c, label=s) for s, c in STATE_COLORS.items()] ax.legend(handles=legend_patches, loc='upper right') scatter = ax.scatter([], [], s=50) day_text = ax.text(0.02, 0.95, '', transform=ax.transAxes) def update(frame): snap = snapshots[frame] xs = [p[0] for p in snap] ys = [p[1] for p in snap] colors = [STATE_COLORS[p[2]] for p in snap] scatter.set_offsets(list(zip(xs, ys))) scatter.set_color(colors) day_text.set_text(f'Giorno {frame}') return scatter, day_text anim = animation.FuncAnimation( fig, update, frames=len(snapshots), interval=100, blit=True) anim.save(filename, writer='pillow', fps=10) print(f'GIF salvata come {filename}') plt.close() # ── Punto di ingresso ─────────────────────────────────────────────── if __name__ == "__main__": cfg = config() history, snapshots = run_simulation(cfg) plot_risultati(history) crea_animazione(snapshots, cfg)
Come eseguire Installa le dipendenze con pip install numpy matplotlib pillow, poi esegui con python SIR_model_discreto.py. Ottieni la curva S/I/R e la GIF SIR_animazione.gif nella stessa cartella.
11
Cosa fare dopo

Hai costruito un modello ad agenti completo in Python partendo da zero. Il passo successivo è sperimentare: cambia i parametri, aggiungi regole, osserva cosa cambia nella curva e nella GIF.

Esperimenti suggeriti

Varia beta (0.1 → 0.9) e osserva come cambia il picco. Riduci grid_size a 8: più densità, più contagio. Aumenta gamma e nota come la curva I si abbassa. Questi sono gli stessi esperimenti che si fanno su NetLogo con gli slider.

Estensioni possibili

Aggiungi uno stato E (esposto, SEIR). Introduci la vaccinazione: una frazione di S inizia come R. Aggiungi mortalità: alcuni I escono dalla simulazione. Ogni estensione aggiunge un campo alla dataclass e una funzione al ciclo.

Continuo vs discreto

Il passo successivo naturale è la versione continua: posizioni float, movimento trigonometrico (cos/sin), distanza euclidea, bordi toroidali. La struttura del codice è identica — cambiano solo la dataclass agente (aggiunge velocity) e le funzioni di movimento e contagio.

Passo successivo: Mesa

Mesa è il framework Python standard per ABM. Ora che conosci la struttura sottostante (agenti, ciclo, procedure), passare a Mesa significa guadagnare visualizzazione integrata e strumenti di batch-run, senza cambiare il modo di pensare.

Riepilogo di ciò che hai imparato Struttura del codice (config → agent → funzioni → run → visualizza) → Agente come dataclass (stato + posizione intera) → Griglia discreta con bordi duri → DIRECTIONS + np.clip per il movimento → Coppie (i,j) + Chebyshev per il contagio → Guarigione probabilistica con γ → Raccolta dati in history + snapshots → Curva S/I/R con Matplotlib → GIF animata con FuncAnimation. Ogni pezzo corrisponde a un costrutto NetLogo preciso.
12
Costruire un ABM da zero — la ricetta generale

Ora che hai visto il SIR pezzo per pezzo, hai tutti gli strumenti per costruire qualsiasi modello ad agenti in Python. Questa sezione distilla il processo in una ricetta riutilizzabile: come si decide cosa va in ogni blocco del codice, in che ordine scrivere le cose, e come capire quali librerie e parametri servono.

Il template che si ripete sempre

Ogni ABM in Python ha la stessa struttura di file, indipendentemente dal fenomeno che si modella:

import
librerie
config
parametri
agent
dataclass
funzioni
regole
run()
ciclo
visualizza()
grafici + GIF
__main__
punto di ingresso
Regola d’oro Non saltare l’ordine. Ogni blocco usa quelli che lo precedono: le funzioni usano agent e config, run() usa le funzioni, __main__ usa tutto. Scrivere in ordine significa che il codice è sempre eseguibile fino al punto in cui sei arrivato.

Dove mettere cosa — blocco per blocco

Ordine consigliato nel file .py
1. import
Le librerie che il codice usa. Vanno sempre in cima. Per il modello discreto: numpy (numeri casuali), matplotlib.pyplot (grafico), matplotlib.animation (GIF), matplotlib.patches (legenda), dataclasses (sempre).
2. config
La @dataclass con tutti i parametri configurabili del modello: dimensioni, probabilità, durata. È il “pannello di controllo” equivalente agli slider di NetLogo. Con valori di default si istanzia con config() senza argomenti.
3. agent
La @dataclass che rappresenta un singolo agente. Va prima delle funzioni perché le funzioni lavorano su oggetti agent — Python deve sapere cos’è agent prima di usarlo. Contiene solo lo stato individuale: stato, posizione. Non i parametri del modello (quelli stanno in config).
4. costanti
Valori che non cambiano mai durante la simulazione: es. DIRECTIONS, STATE_COLORS. In MAIUSCOLO per convenzione. Vanno dopo le dataclass e prima delle funzioni che le usano.
5. funzioni
Una funzione per ogni regola del modello: crea_agenti(), muovi_agenti(), contagio(), guarigione(), conta_stati(). L’ordine suggerito è quello in cui vengono chiamate nel ciclo.
6. run()
Il ciclo principale. Chiama tutte le funzioni ad ogni tick. Raccoglie i dati in history (conteggi) e snapshots (posizioni). Restituisce entrambi con return.
7. visualizza()
La curva S/I/R con Matplotlib (plot_risultati) e l’animazione GIF (crea_animazione). Va dopo run() perché riceve i suoi output.
8. __main__
if __name__ == "__main__": è sempre l’ultimissima cosa nel file. Python assegna __name__ = "__main__" solo quando esegui il file direttamente — non quando lo importi. Istanzia config(), chiama run(), chiama le funzioni di visualizzazione.

Come decidere gli attributi dell’agente

La @dataclass agent contiene tutto ciò che l’agente deve ricordare tra un tick e l’altro. Per decidere quali campi mettere, fai queste domande:

DomandaSe sì → aggiungiEsempio SIR discreto
L’agente ha uno stato discreto che cambia nel tempo? un campo state: str state = 'S' / 'I' / 'R'
L’agente si muove su una griglia discreta? pos_x: int, pos_y: int Celle intere, no float, no angolo
L’agente si muove in uno spazio continuo? pos_x: float, pos_y: float, velocity: float Solo nel modello continuo
Una transizione avviene dopo N tick (deterministica)? un timer: int = 0 Non presente nel SIR discreto (guarigione probabilistica)
L’agente ha una quantità che varia (energia, età)? un campo numerico, es. energy: float = 100.0 Non presente nel SIR base
Cosa NON mettere nella dataclass Non mettere i parametri del modello (beta, gamma, grid_size) — quelli vanno in config. Non mettere la lista degli altri agenti — viene passata come argomento alle funzioni. La dataclass agent contiene solo lo stato individuale dell’agente.

Come capire quali librerie importare

LibreriaQuando importarlaFunzioni tipicamente usate
numpy Se usi numeri casuali o operazioni su array np.random.randint(), np.random.rand(), np.clip()
matplotlib.pyplot Per la curva S/I/R nel tempo plt.plot(), plt.legend(), plt.show()
matplotlib.animation Per la GIF animata del mondo animation.FuncAnimation(), anim.save()
matplotlib.patches Per la legenda colorata nell’animazione Patch(color=..., label=...)
dataclasses Sempre — serve per @dataclass from dataclasses import dataclass
math Solo nel modello continuo (trigonometria) math.sin, math.cos, math.pi, math.hypot

Lo scheletro di partenza — copia e adatta

Questo è il template minimo da cui partire per qualsiasi nuovo modello discreto. Le parti in grigio sono sempre identiche; le parti in verde sono quelle che tu devi riempire:

# ── 1. IMPORT ───────────────────────────────────────────────────────── import numpy as np import matplotlib.pyplot as plt import matplotlib.animation as animation from matplotlib.patches import Patch from dataclasses import dataclass # ── 2. CONFIG ───────────────────────────────────────────────────────── @dataclass class config: population: int = 100 # ← adatta al tuo modello grid_size: int = 16 days: int = 100 # ... altri parametri configurabili # ── 3. AGENT ────────────────────────────────────────────────────────── @dataclass class agent: state: str = 'A' # ← decidi gli stati del tuo modello pos_x: int = 0 pos_y: int = 0 # ── 4. COSTANTI ─────────────────────────────────────────────────────── DIRECTIONS = [(0, 1), (0, -1), (1, 0), (-1, 0)] # riusa sempre questa STATE_COLORS = {'A': 'steelblue', ...} # ← adatta ai tuoi stati # ── 5. FUNZIONI DI COMPORTAMENTO ────────────────────────────────────── def crea_agenti(cfg): agents = [] for _ in range(cfg.population): a = agent() a.pos_x = np.random.randint(-cfg.grid_size, cfg.grid_size + 1) a.pos_y = np.random.randint(-cfg.grid_size, cfg.grid_size + 1) agents.append(a) return agents def muovi_agenti(agents, cfg): # riusa sempre questa for a in agents: dx, dy = DIRECTIONS[np.random.randint(0, 4)] a.pos_x = np.clip(a.pos_x + dx, -cfg.grid_size, cfg.grid_size) a.pos_y = np.clip(a.pos_y + dy, -cfg.grid_size, cfg.grid_size) def interagisce(agents, cfg): # ← scrivi le tue regole qui for i in range(len(agents)): for j in range(i + 1, len(agents)): a, b = agents[i], agents[j] d = max(abs(a.pos_x - b.pos_x), abs(a.pos_y - b.pos_y)) if d > 1: continue # ... logica di interazione def aggiorna_stato(agents, cfg): # ← scrivi le tue regole qui for a in agents: pass # ── 6. CICLO PRINCIPALE ─────────────────────────────────────────────── def run_simulation(cfg): agents = crea_agenti(cfg) history = {} # ← dizionario per i tuoi stati snapshots = [] for day in range(cfg.days): muovi_agenti(agents, cfg) interagisce(agents, cfg) aggiorna_stato(agents, cfg) # raccoglie dati qui snap = [(a.pos_x, a.pos_y, a.state) for a in agents] snapshots.append(snap) return history, snapshots # ── 7. VISUALIZZAZIONE ──────────────────────────────────────────────── def plot_risultati(history): ... # curva nel tempo def crea_animazione(snapshots, cfg): ... # GIF del mondo # ── 8. PUNTO DI INGRESSO ────────────────────────────────────────────── if __name__ == "__main__": cfg = config() history, snapshots = run_simulation(cfg) plot_risultati(history) crea_animazione(snapshots, cfg)
Come usare questo template Parti sempre dal fenomeno: cosa vuoi osservare emergere? Da lì definisci gli stati dell’agente, poi le transizioni (= le funzioni), poi i parametri che le transizioni usano (= config). Le funzioni muovi_agenti e crea_animazione sono quasi sempre identiche tra modelli — puoi riusarle direttamente. Se una funzione non riesci a scriverla subito, metti pass come corpo e completala dopo.