Salta al contenuto principale

07 - Reasoning models: o1, DeepSeek-R1, RLHF

Tre idee sotto l'etichetta dei modelli che ragionano: DPO, rinforzo con ricompensa verificabile e voto di maggioranza, ciascuna provata in piccolo con numeri.

Cosa ne pensi di questo articolo?

07 - Reasoning models: o1, DeepSeek-R1, RLHF
Intelligenza Artificiale Modernafedericocalo.dev

In Breve

Tre idee sotto l'etichetta dei modelli che ragionano: DPO, rinforzo con ricompensa verificabile e voto di maggioranza, ciascuna provata in piccolo con numeri.

Un modello «che ragiona» tende a rispondere più lentamente di uno normale, perché prima di dare la risposta genera più testo. L'etichetta, però, copre tre cose diverse, e confonderle porta a conclusioni sbagliate: come si orienta un modello verso ciò che le persone preferiscono (RLHF, DPO), come lo si addestra su problemi di cui si può verificare la risposta (apprendimento per rinforzo con ricompense a regole, per esempio GRPO) e come si spende più calcolo al momento della risposta (campionare più catene di ragionamento e votare). In questo articolo costruisco ciascuna delle tre in miniatura, con numeri che puoi rifare, e riporto dai lavori originali solo ciò che ho letto.

Questo è il settimo articolo della serie Intelligenza Artificiale Moderna. Se arrivi da quello sul Model Context Protocol o da quello sugli agenti, nota che il ragionamento esteso è un modo diverso di usare più calcolo: lì il calcolo in più passa da strumenti esterni, qui da più testo generato. Il confronto con la crescita dei modelli lo trovi nell'articolo su scaling law e capacità emergenti. Basta Python con numpy, e nessun modello viene scaricato né chiamato.

Tre cose diverse sotto la stessa etichetta

  • Allineamento alle preferenze: si hanno coppie di risposte, una preferita all'altra, e si sposta il modello verso la preferita.
  • Rinforzo con ricompensa verificabile: si fa generare al modello più risposte allo stesso problema, un controllo automatico dice quali sono corrette, e si rafforzano quelle.
  • Calcolo al momento della risposta: a modello fermo, si generano più tentativi e se ne combinano i risultati.

Le tre voci agiscono in momenti diversi (addestramento sulle preferenze, addestramento sui problemi verificabili, uso del modello già addestrato) e hanno costi diversi: le prime due si pagano una volta, la terza a ogni domanda. Nei sistemi reali si combinano, ma i lavori che le hanno introdotte le esaminano una alla volta, ed è l'unico modo onesto di capire che cosa ciascuna fa. Un limite dichiarato subito: la pagina di annuncio di o1 non era raggiungibile dal mio ambiente, quindi di quella famiglia riporto soltanto ciò che dice l'abstract della sua scheda di sistema, cioè che i modelli sono addestrati con apprendimento per rinforzo su larga scala a ragionare con una catena di pensiero. Niente altro su o1 è affermato qui.

RLHF in tre passi, in una frase ciascuno

Il lavoro su InstructGPT (Ouyang e colleghi, 2022) parte da un'osservazione: rendere un modello più grande non lo rende per questo più capace di seguire ciò che l'utente vuole, perché può produrre risposte false, tossiche o inutili. La ricetta che propongono ha tre passi: raccogliere dimostrazioni scritte da persone e affinare il modello con apprendimento supervisionato, raccogliere classifiche di più risposte del modello e addestrare su queste una ricompensa, poi affinare ancora il modello con apprendimento per rinforzo a partire da quel feedback umano. Il lavoro DeepSeekMath, descrivendo lo stesso schema, aggiunge che di solito si somma alla ricompensa una penalità, per ogni token, sulla distanza dal modello di riferimento, tipicamente il modello affinato al primo passo: serve a evitare che il modello si allontani troppo e sfrutti i difetti della ricompensa.

DPO: la stessa preferenza, senza ciclo di rinforzo

Il passo costoso di quella ricetta è il terzo, perché richiede di far generare testo al modello durante l'addestramento. Il lavoro sulla Direct Preference Optimization (Rafailov e colleghi, 2023) mostra che si può riparametrizzare la ricompensa in modo che il problema si risolva con una semplice perdita di classificazione sulle coppie di preferenza, senza campionare dal modello durante l'addestramento: gli autori la descrivono come stabile, efficace e leggera, e riportano che nei loro esperimenti uguaglia o supera l'RLHF basato su PPO nel controllo del sentimento, nel riassunto e nel dialogo a un turno. La perdita (equazione 7 del lavoro) è il logaritmo negativo di una sigmoide applicata a β volte la differenza fra due rapporti di log-probabilità, quello della risposta preferita e quello della scartata, ciascuno rispetto al modello di riferimento.

Per toccarla con mano costruisco la politica più piccola possibile: un solo prompt, quattro risposte candidate e quattro numeri (i logit) al posto di una rete. Il modello di riferimento è fisso, la risposta 1 è la preferita e la 2 è la scartata. Con questa semplificazione il gradiente ha una forma chiusa, e lo verifico contro le differenze finite.

"""DPO su una politica minuscola: 4 risposte candidate, un solo prompt.
La "politica" sono 4 logit; la perdita e' quella dell'articolo DPO (eq. 7)."""
import numpy as np


def log_softmax(z):
    z = z - z.max()
    return z - np.log(np.exp(z).sum())


THETA_REF = np.array([0.5, 0.0, 0.3, -0.5])   # politica di riferimento, congelata
VINCENTE, PERDENTE = 1, 2                       # la coppia di preferenza


def margine(theta, beta):
    lp, lr = log_softmax(theta), log_softmax(THETA_REF)
    return beta * ((lp[VINCENTE] - lr[VINCENTE]) - (lp[PERDENTE] - lr[PERDENTE]))


def perdita(theta, beta):
    return float(np.log1p(np.exp(-margine(theta, beta))))   # -log sigmoid(margine)


def gradiente(theta, beta):
    s = 1.0 / (1.0 + np.exp(margine(theta, beta)))           # sigmoid(-margine)
    e = np.zeros(4)
    e[VINCENTE], e[PERDENTE] = 1.0, -1.0   # grad log pi(w) - grad log pi(l) = e_w - e_l
    return -beta * s * e


def kl(theta):
    p, q = np.exp(log_softmax(theta)), np.exp(log_softmax(THETA_REF))
    return float((p * (np.log(p) - np.log(q))).sum())


# secondo calcolo: differenze finite contro il gradiente analitico
rng = np.random.default_rng(0)
errore_max = 0.0
for _ in range(20):
    th, beta, h = rng.normal(size=4), rng.uniform(0.05, 2.0), 1e-6
    num = np.array([(perdita(th + h * np.eye(4)[i], beta) - perdita(th - h * np.eye(4)[i], beta)) / (2 * h)
                    for i in range(4)])
    errore_max = max(errore_max, float(np.abs(num - gradiente(th, beta)).max()))
print(f"gradiente analitico contro differenze finite: errore massimo {errore_max:.1e}")
print(f"perdita iniziale {perdita(THETA_REF.copy(), 0.1):.4f} (attesa ln 2 = {np.log(2):.4f})")

for beta in (0.1, 1.0):
    th = THETA_REF.copy()
    print(f"--- beta = {beta}")
    print("passo  perdita  p(vinc)  p(perd)  p(alt0)  p(alt3)   KL")
    for passo in range(0, 201):
        if passo in (0, 10, 50, 200):
            p = np.exp(log_softmax(th))
            print(f"{passo:5d}  {perdita(th, beta):.4f}   {p[1]:.4f}   {p[2]:.4f}   "
                  f"{p[0]:.4f}   {p[3]:.4f}  {kl(th):.3f}")
        th = th - 0.5 * gradiente(th, beta)

# Output:
# gradiente analitico contro differenze finite: errore massimo 2.2e-10
# perdita iniziale 0.6931 (attesa ln 2 = 0.6931)
# --- beta = 0.1
# passo  perdita  p(vinc)  p(perd)  p(alt0)  p(alt3)   KL
#     0  0.6931   0.2172   0.2931   0.3580   0.1317  0.000
#    10  0.6687   0.2790   0.2297   0.3592   0.1321  0.015
#    50  0.5824   0.5483   0.0704   0.2787   0.1025  0.312
#   200  0.3733   0.9585   0.0005   0.0300   0.0110  1.318
# --- beta = 1.0
# passo  perdita  p(vinc)  p(perd)  p(alt0)  p(alt3)   KL
#     0  0.6931   0.2172   0.2931   0.3580   0.1317  0.000
#    10  0.0990   0.5354   0.0752   0.2847   0.1047  0.292
#    50  0.0203   0.7404   0.0205   0.1748   0.0643  0.682
#   200  0.0050   0.8569   0.0058   0.1004   0.0369  0.979

Le funzioni che contano sono tre. margine calcola la quantità dentro la sigmoide, cioè β per la differenza fra i due rapporti di log-probabilità; perdita ne prende il logaritmo negativo della sigmoide; gradiente sfrutta il fatto che, per un softmax, il gradiente del logaritmo della probabilità di una risposta è un vettore con 1 nella sua posizione meno la distribuzione, per cui nella differenza fra preferita e scartata la distribuzione si cancella e restano solo +1 e -1. Il parametro beta, che qui vale 0,1 o 1, ha il ruolo che il lavoro gli assegna: regola quanto il modello può allontanarsi dal riferimento. Il controllo con le differenze finite dà un errore massimo dell'ordine di 1e-10, e la perdita iniziale coincide con ln 2, come deve quando il modello è uguale al riferimento.

Leggi l'uscita in tre punti. Primo: con β = 1 la perdita crolla in dieci passi (0,099) ma la probabilità della risposta preferita sale solo a 0,54 e a 0,86 dopo 200 passi; con β = 0,1 la perdita scende lentamente (0,37 a 200 passi) ma la preferita arriva a 0,96. Una perdita bassa non significa dunque una probabilità alta: misura il margine rispetto al riferimento, e con β grande il margine si ottiene con un piccolo spostamento. Secondo: la distanza dal riferimento (la colonna KL) a 200 passi è 0,98 con β = 1 e 1,32 con β = 0,1, quindi in questo giocattolo il β grande tiene il modello più vicino, come previsto. Terzo: le risposte che non compaiono nella coppia, alt0 e alt3, cambiano comunque probabilità, perché le probabilità si ricalcolano insieme: la perdita non ti garantisce nulla su ciò che non hai confrontato.

GRPO: rinforzo con ricompense verificabili

Per i problemi di matematica o di programmazione c'è un modo di decidere se una risposta è giusta senza persone: confrontare il risultato finale con quello noto, o far girare i test. Nel lavoro su DeepSeek-R1 il modello DeepSeek-R1-Zero è addestrato proprio così, con ricompense a regole di due tipi: una di accuratezza, che verifica la correttezza (per esempio la risposta in un formato prestabilito, o un compilatore sui casi di prova) e una di formato, che chiede di racchiudere il ragionamento in marcatori dedicati. Gli autori scrivono di non usare ricompense da modelli neurali per i compiti di ragionamento, perché hanno osservato che sono esposte al reward hacking, cioè allo sfruttamento dei difetti della ricompensa, durante un rinforzo su larga scala.

L'algoritmo è GRPO, introdotto in DeepSeekMath (Shao e colleghi, 2024): per ogni domanda si campiona un gruppo di risposte dalla politica corrente, e al posto di una funzione di valore appresa come riferimento si usa la ricompensa media del gruppo. Il vantaggio di ogni risposta è la sua ricompensa meno la media del gruppo, divisa per la deviazione standard del gruppo. Il listato calcola quel vantaggio, per ogni numero k di risposte corrette in un gruppo di 8 con ricompensa 0 o 1, e poi lo usa per addestrare un bandito: una politica che sceglie fra cinque «strategie» di cui conosco la probabilità di successo.

"""Vantaggio relativo al gruppo (GRPO) e un bandito con ricompensa verificabile.
Il "modello" e' una politica softmax su 5 strategie con probabilita' di successo note."""
import numpy as np

# 1) il vantaggio: (r - media) / deviazione standard, dentro il gruppo
G = 8
print("k corrette su 8 -> vantaggio di una risposta corretta / di una sbagliata")
for k in range(0, G + 1):
    r = np.array([1.0] * k + [0.0] * (G - k))
    sd = r.std()                           # deviazione standard di popolazione
    if sd == 0:
        print(f"  k={k}: tutte uguali, deviazione 0, vantaggio indefinito (qui messo a 0)")
        continue
    a = (r - r.mean()) / sd
    print(f"  k={k}: corretta {a[0]:+.3f}, sbagliata {a[-1]:+.3f}")

# 2) un bandito: la politica impara quale strategia riesce piu' spesso
P_SUCCESSO = np.array([0.10, 0.20, 0.30, 0.50, 0.80])


def softmax(z):
    e = np.exp(z - z.max())
    return e / e.sum()


def addestra(seme, passi=400, lr=0.3, g=8):
    rng = np.random.default_rng(seme)
    theta = np.zeros(5)
    for _ in range(passi):
        pi = softmax(theta)
        scelte = rng.choice(5, size=g, p=pi)
        r = (rng.random(g) < P_SUCCESSO[scelte]).astype(float)
        sd = r.std()
        if sd == 0:
            continue                         # gruppo uniforme: nessun segnale
        adv = (r - r.mean()) / sd
        grad = np.zeros(5)
        for y, a in zip(scelte, adv):
            e = -pi.copy()
            e[y] += 1.0                      # grad log pi(y) = e_y - pi
            grad += a * e / g
        theta += lr * grad
    return float(softmax(theta) @ P_SUCCESSO)


finali = [addestra(s) for s in range(20)]
print(f"ricompensa attesa iniziale (politica uniforme): {P_SUCCESSO.mean():.3f}")
print(f"dopo 400 aggiornamenti, media su 20 semi: {np.mean(finali):.3f} "
      f"(min {min(finali):.3f}, max {max(finali):.3f}); migliore strategia possibile: {P_SUCCESSO.max():.2f}")

# secondo calcolo: salita esatta del gradiente della ricompensa attesa, senza campionare
theta = np.zeros(5)
for _ in range(400):
    pi = softmax(theta)
    atteso = pi @ P_SUCCESSO
    theta += 0.3 * pi * (P_SUCCESSO - atteso)   # gradiente analitico di E[r]
print(f"controllo con gradiente esatto, stessi passi: {softmax(theta) @ P_SUCCESSO:.3f}")

# Output:
# k corrette su 8 -> vantaggio di una risposta corretta / di una sbagliata
#   k=0: tutte uguali, deviazione 0, vantaggio indefinito (qui messo a 0)
#   k=1: corretta +2.646, sbagliata -0.378
#   k=2: corretta +1.732, sbagliata -0.577
#   k=3: corretta +1.291, sbagliata -0.775
#   k=4: corretta +1.000, sbagliata -1.000
#   k=5: corretta +0.775, sbagliata -1.291
#   k=6: corretta +0.577, sbagliata -1.732
#   k=7: corretta +0.378, sbagliata -2.646
#   k=8: tutte uguali, deviazione 0, vantaggio indefinito (qui messo a 0)
# ricompensa attesa iniziale (politica uniforme): 0.380
# dopo 400 aggiornamenti, media su 20 semi: 0.796 (min 0.794, max 0.798); migliore strategia possibile: 0.80
# controllo con gradiente esatto, stessi passi: 0.792

Nel codice, sd è la deviazione standard di popolazione del gruppo: nei due lavori che ho letto non ho trovato precisato quale deviazione standard si usi, per cui questa è una mia scelta. Le due righe ai bordi mostrano un fatto che dipende dalla formula: quando tutte le risposte hanno la stessa ricompensa, tutte giuste o tutte sbagliate, la deviazione è zero e il vantaggio è indefinito; la mia implementazione salta quel gruppo, e il modello non impara nulla da un problema troppo facile o troppo difficile. Nelle altre righe, quando una sola risposta su otto è corretta riceve +2,646 e le sbagliate -0,378 ciascuna, perché un successo raro è il segnale più informativo; con sette corrette su otto l'asimmetria si rovescia.

Il bandito parte dalla politica uniforme, con ricompensa attesa 0,380, e dopo 400 aggiornamenti arriva a 0,796 in media su 20 semi, con minimo 0,794 e massimo 0,798, contro un massimo possibile di 0,80. Come secondo calcolo, la salita esatta del gradiente della ricompensa attesa, senza campionare, dà 0,792 con gli stessi passi e la stessa velocità di apprendimento. La vicinanza dei due numeri non dimostra che i metodi siano equivalenti, perché la normalizzazione cambia la scala dell'aggiornamento: dice che il segnale del gruppo basta a trovare la strategia migliore in un problema dove la ricompensa è verificabile. Un LLM sceglie sequenze di token e non fra cinque strategie, e il mio listato non ha né il ritaglio del rapporto fra politiche né la penalità KL della formula del lavoro: è il meccanismo del baseline di gruppo, non GRPO intero.

Gli stessi autori riportano, per DeepSeek-R1-Zero, che la precisione su AIME 2024 (pass@1 medio) sale durante l'addestramento da 15,6% a 77,9% e che la lunghezza media della risposta cresce, tanto che scrivono che il modello impara a risolvere i compiti con più tempo di pensiero. Sono cifre dei suoi autori, su un benchmark e un modello specifici, e non le ho riprodotte: l'esperimento sopra spiega il meccanismo, non il risultato.

Più calcolo al momento della risposta: votare

Il terzo modo di spendere calcolo non cambia il modello. La self-consistency (Wang e colleghi, 2022) sostituisce la decodifica greedy usata con il prompt a catena di pensiero: campiona vari percorsi di ragionamento e sceglie la risposta più coerente fra quelli, marginalizzando sui percorsi. Gli autori riportano miglioramenti su benchmark aritmetici e di buon senso, fra cui +17,9% su GSM8K. L'intuizione è che un problema complesso ammette molti modi di ragionare che portano alla stessa risposta corretta, mentre gli errori tendono a disperdersi.

Quest'ultima frase è però un'ipotesi, e il listato la mette alla prova: ogni «campione» è una risposta tirata da una distribuzione fissa, di cui la risposta 0 è la corretta. Nel primo caso la corretta è la più probabile (0,40 contro 0,15 per ciascuna delle altre quattro). Nel secondo una risposta sbagliata è più probabile della corretta (0,45 contro 0,30). Calcolo la probabilità che la corretta vinca il voto in due modi: esattamente, enumerando tutti i conteggi possibili con una distribuzione multinomiale (i pareggi si dividono in parti uguali), e per simulazione con seme fisso.

"""Piu' campioni, piu' calcolo al momento della risposta: il voto di maggioranza.
Ogni "campione" e' una risposta tirata da una distribuzione fissa; non c'e' alcun modello."""
import itertools
import math
import numpy as np

# probabilita' che un campione dia ciascuna risposta; la 0 e' quella corretta
CASI = {
    "corretta la piu' probabile (0.40 contro 0.15 ciascuna)": [0.40, 0.15, 0.15, 0.15, 0.15],
    "una sbagliata piu' probabile (0.30 contro 0.45)": [0.30, 0.45, 0.10, 0.10, 0.05],
}
NS = [1, 5, 9, 17, 33]


def esatto(p, n):
    """Probabilita' esatta che la risposta 0 vinca il voto (pareggi spartiti in parti uguali)."""
    k, tot = len(p), 0.0
    for conteggi in itertools.product(range(n + 1), repeat=k - 1):
        resto = n - sum(conteggi)
        if resto < 0:
            continue
        c = (resto,) + conteggi            # c[0] = voti alla risposta corretta
        prob = math.factorial(n)
        for ci, pi in zip(c, p):
            prob *= pi ** ci / math.factorial(ci)
        massimo = max(c)
        if c[0] == massimo:
            tot += prob / c.count(massimo)
    return tot


def simulato(p, n, prove, seme):
    rng = np.random.default_rng(seme)
    voti = rng.multinomial(n, p, size=prove)
    massimo = voti.max(axis=1, keepdims=True)
    vincitori = voti == massimo
    return float(((voti[:, 0] == massimo[:, 0]) / vincitori.sum(axis=1)).mean())


for nome, p in CASI.items():
    print(nome)
    for n in NS:
        e = esatto(p, n)
        s = simulato(p, n, 40000, seme=n)
        print(f"  n={n:2d}: esatta {e:.3f}  simulata {s:.3f}  (differenza {abs(e - s):.3f})")

# Output:
# corretta la piu' probabile (0.40 contro 0.15 ciascuna)
#   n= 1: esatta 0.400  simulata 0.402  (differenza 0.002)
#   n= 5: esatta 0.549  simulata 0.550  (differenza 0.001)
#   n= 9: esatta 0.665  simulata 0.662  (differenza 0.003)
#   n=17: esatta 0.808  simulata 0.805  (differenza 0.003)
#   n=33: esatta 0.934  simulata 0.938  (differenza 0.003)
# una sbagliata piu' probabile (0.30 contro 0.45)
#   n= 1: esatta 0.300  simulata 0.303  (differenza 0.003)
#   n= 5: esatta 0.303  simulata 0.306  (differenza 0.004)
#   n= 9: esatta 0.282  simulata 0.280  (differenza 0.002)
#   n=17: esatta 0.232  simulata 0.228  (differenza 0.004)
#   n=33: esatta 0.158  simulata 0.158  (differenza 0.000)

Il primo caso mostra l'effetto della self-consistency: la probabilità di vincere sale da 0,40 con un campione a 0,665 con 9 e a 0,934 con 33. Il secondo caso mostra il suo rovescio: con una sbagliata più probabile della corretta, votare peggiora le cose, perché amplifica la risposta più frequente, quale che sia. Passa da 0,300 a 0,158 con 33 campioni, e nulla nel voto ti avvisa che stai rafforzando l'errore. La simulazione e il calcolo esatto concordano entro 0,004, compatibile con l'errore statistico di circa 0,0025 che hanno 40.000 prove. Il costo è lineare: n campioni significano n generazioni complete, quindi n volte i token, e per questo il calcolo al momento della risposta si paga a ogni domanda.

Che cosa non dimostrano gli esperimenti

I tre listati usano politiche con pochi numeri al posto di modelli linguistici, e dimostrano meccanismi, non risultati di un prodotto: nessuno dice quanto sia bravo un modello vero. Le cifre sui benchmark (17,9%, 15,6% e 77,9%) sono quelle dei rispettivi autori, riportate come tali. Il raggio d'azione di ciascuna tecnica dipende da cose che qui non si vedono: la qualità delle coppie di preferenza, la bontà del verificatore automatico, la distribuzione reale delle risposte di un modello. Nessun articolo di questa serie ha per tesi l'intelligenza artificiale in sé, e questo meno degli altri: parla di tre metodi, e li giudica per ciò che fanno in un caso verificabile.

Domande frequenti

Un modello che ragiona è sempre migliore di uno che risponde subito?

No. Più calcolo per domanda costa di più e, come mostra il secondo caso del voto, può amplificare un errore sistematico invece di correggerlo. Aiuta quando gli errori si disperdono e la risposta corretta è la più probabile.

DPO sostituisce RLHF?

Gli autori di DPO riportano che nei loro esperimenti (controllo del sentimento, riassunto, dialogo a un turno) uguaglia o supera l'RLHF basato su PPO con un procedimento più semplice. Non è un'affermazione generale: dipende dal compito e dai dati di preferenza.

Quanto costa votare su più campioni?

Il costo cresce linearmente con il numero di campioni, perché ognuno è una generazione completa: 33 campioni costano circa 33 volte uno. Un prezzo in euro dipende dal modello e dal fornitore e qui non è affermato.

Che cosa sai fare adesso

Sai distinguere le tre cose che si nascondono sotto «modello che ragiona» e sai dire quando ciascuna agisce e che cosa costa. Sai scrivere la perdita DPO e verificare il suo gradiente, leggere un vantaggio di gruppo e prevedere quando un gruppo non dà alcun segnale, e sai provare con un calcolo esatto se un voto di maggioranza aiuta o danneggia. Il passo successivo è pratico: se vuoi far girare un modello aperto sulla tua macchina, conviene sapere quanta memoria serve e come si serve più di una richiesta alla volta, tema dell'ultimo articolo della serie.

Fonti

I tre listati sono stati eseguiti con Python 3.14.4 e numpy 2.3.5 (python3 -I), due volte ciascuno, con uscita identica; ogni esecuzione dura meno di un secondo. I generatori casuali hanno semi fissi, nessun modello è stato scaricato o chiamato. Le formule dei lavori sono riportate dal testo letto; i risultati numerici sui benchmark sono dei loro autori e non sono stati riprodotti.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti