Salta al contenuto principale

02 - BERT Spiegato: Architettura e Pretraining

Guida completa a BERT Spiegato: Architettura e Pretraining: architettura, implementazione pratica e best practices per developer e team tecnici.

Cosa ne pensi di questo articolo?

02 - BERT Spiegato: Architettura e Pretraining
NLP ModernoFoto:Brett SaylesPexels

In Breve

Guida completa a BERT Spiegato: Architettura e Pretraining: architettura, implementazione pratica e best practices per developer e team tecnici.

BERT Spiegato: Architettura, Pretraining e Fine-tuning

Il 2018 ha segnato un punto di svolta nella storia del Natural Language Processing. Con la pubblicazione di BERT (Bidirectional Encoder Representations from Transformers), il team di Google AI ha introdotto un modello che ha ridefinito lo stato dell'arte su 11 benchmark NLP contemporaneamente. Per la prima volta, un singolo modello pre-addestrato poteva essere adattato a task diversissimi tra loro (classificazione, question answering, NER) ottenendo risultati superiori a tutti i sistemi specializzati precedenti.

Ma cosa rende BERT cosi rivoluzionario? La risposta sta in tre innovazioni fondamentali: la bidirezionalita profonda, il pre-training su larga scala e la semplicità del fine-tuning per i task downstream. In questo articolo analizzeremo ogni aspetto dell'architettura BERT, dai meccanismi interni dell'attention fino all'implementazione pratica con HuggingFace, passando per le varianti che ne sono derivate.

Questo e il secondo articolo della serie NLP Moderno: da BERT ai LLM. Se non hai ancora letto il primo articolo sui fondamenti (tokenizzazione, embeddings e pipeline NLP), ti consiglio di farlo prima di procedere: molti dei concetti qui trattati si costruiscono su quelle basi.

Cosa Imparerai

  • perchè BERT ha rappresentato una rivoluzione nell'NLP e i limiti dei modelli precedenti
  • L'architettura encoder-only del Transformer alla base di BERT
  • Come funziona la self-attention multi-head e le formule matematiche
  • Input representation: token, segment e position embeddings
  • Le due strategie di pre-training: Masked Language Model (MLM) e Next Sentence Prediction (NSP)
  • La tokenizzazione WordPiece e i token speciali [CLS], [SEP], [MASK]
  • Come fare fine-tuning per classificazione, NER, question answering
  • Implementazione pratica completa con HuggingFace Transformers
  • Varianti di BERT: RoBERTa, ALBERT, DistilBERT, DeBERTa, ELECTRA
  • BERT per la lingua italiana: modelli disponibili e confronto
  • Limiti di BERT e come i modelli successivi li hanno superati

Panoramica della Serie

# Articolo Focus
1Fondamenti NLPTokenizzazione, Embeddings, Pipeline
2Sei qui - BERT e TransformerArchitettura Attention, Pre-training
3Sentiment AnalysisClassificazione del testo con BERT
4Named Entity RecognitionEstrazione di entità dal testo
5HuggingFace TransformersLibreria e modelli pre-addestrati
6Fine-Tuning di ModelliAdattare BERT al tuo dominio
7NLP per l'ItalianoModelli e risorse per la lingua italiana
8Da BERT ai LLMGPT, LLaMA e la generazione di testo

1. perchè BERT ha Rivoluzionato l'NLP

Per comprendere l'impatto di BERT, dobbiamo tornare al panorama NLP pre-2018 e capire quali problemi fondamentali i modelli precedenti non riuscivano a risolvere.

1.1 I Limiti di Word2Vec e GloVe

Come abbiamo visto nel primo articolo della serie, Word2Vec (2013) e GloVe (2014) sono stati una svolta importante: per la prima volta, le parole venivano rappresentate come vettori densi in uno spazio continuo, dove relazioni semantiche come "re - uomo + donna = regina" emergevano naturalmente dalla geometria dello spazio.

Tuttavia, questi modelli soffrono di un limite fondamentale: producono rappresentazioni statiche. Ogni parola ha un unico vettore, indipendentemente dal contesto in cui appare. Consideriamo la parola "banco":

Il Problema delle Rappresentazioni Statiche

  • "Ho depositato i soldi in banco" (istituto bancario)
  • "Il banco di scuola era troppo piccolo" (mobile)
  • "Un banco di nebbia copriva la valle" (massa di nebbia)
  • "Il banco di pesci era enorme" (gruppo di pesci)

Con Word2Vec, la parola "banco" ha un unico vettore che e una media confusa di tutti questi significati. Il modello non ha modo di distinguere il contesto.

1.2 ELMo: Il Primo Passo Verso il Contesto

Nel 2018, prima di BERT, ELMo (Embeddings from Language Models) di AllenAI aveva già tentato di risolvere il problema dei contesti. ELMo utilizzava una rete biLSTM (bidirectional Long Short-Term Memory) pre-addestrata su un compito di language modeling per produrre embeddings contestuali.

Il problema di ELMo era duplice: primo, la bidirezionalita era superficiale, nel senso che due LSTM (una forward, una backward) venivano concatenate, ma non interagivano durante il processing. Secondo, le LSTM soffrono di un information bottleneck: le dipendenze a lungo raggio vengono progressivamente "dimenticate" man mano che la sequenza si allunga.

1.3 La Svolta: Bidirezionalita Profonda

BERT risolve entrambi i problemi grazie al meccanismo di self-attention del Transformer. Ogni token in una sequenza può "guardare" tutti gli altri token simultaneamente, sia quelli a sinistra che quelli a destra. Questa bidirezionalita non e la concatenazione di due modelli separati (come in ELMo), ma un'interazione profonda che avviene a ogni singolo layer dell'architettura.

Confronto: Approcci alla Bidirezionalita

Modello Tipo Contesto Limitazione
Word2Vec/GloVe Statico Nessun contesto Un vettore per parola
GPT-1 Unidirezionale (sx -> dx) Solo contesto precedente Non vede il futuro
ELMo Bidirezionale superficiale Contesto concatenato Nessuna interazione tra direzioni
BERT Bidirezionale profondo Contesto completo a ogni layer Solo encoder (no generazione)

Questa bidirezionalita profonda e il motivo per cui BERT e stato in grado di stabilire nuovi record su 11 benchmark NLP al momento della sua pubblicazione, tra cui GLUE, SQuAD 1.1, SQuAD 2.0 e MultiNLI.

2. Architettura BERT: L'Encoder Transformer

BERT e un modello encoder-only, cioe utilizza solo la parte encoder dell'architettura Transformer originale descritta nel paper "Attention Is All You Need" (2017). Vediamo ogni componente nel dettaglio.

2.1 Visione d'Insieme dell'Architettura

L'architettura di BERT può essere visualizzata come una serie di blocchi Transformer impilati verticalmente. Ogni blocco contiene un layer di multi-head self-attention seguito da un feed-forward network, con layer normalization e connessioni residue.

Schema dell'Architettura BERT (ASCII)

        Input: [CLS] Il gatto si siede sul tappeto [SEP]
                  |       |     |    |     |    |       |
            +-----+-------+-----+----+-----+----+-------+-----+
            |              Token Embeddings                     |
            |            + Segment Embeddings                   |
            |            + Position Embeddings                  |
            +-----+-------+-----+----+-----+----+-------+-----+
                  |       |     |    |     |    |       |
            +---------------------------------------------------------+
            |           Transformer Encoder Block 1                   |
            |  +---------------------------------------------------+  |
            |  |         Multi-Head Self-Attention                 |  |
            |  |   Q = XWq    K = XWk    V = XWv                  |  |
            |  |   Attention(Q,K,V) = softmax(QK^T/sqrt(dk))V     |  |
            |  +---------------------------------------------------+  |
            |  |         Add & Layer Norm                          |  |
            |  +---------------------------------------------------+  |
            |  |         Feed-Forward Network                      |  |
            |  |   FFN(x) = max(0, xW1 + b1)W2 + b2               |  |
            |  +---------------------------------------------------+  |
            |  |         Add & Layer Norm                          |  |
            +---------------------------------------------------------+
                  |       |     |    |     |    |       |
                 ...     ...   ...  ...   ...  ...     ...
                  |       |     |    |     |    |       |
            +---------------------------------------------------------+
            |           Transformer Encoder Block L (12 o 24)         |
            +---------------------------------------------------------+
                  |       |     |    |     |    |       |
            [CLS]out  T1   T2   T3   T4   T5   T6   [SEP]out
              |
          Pooling --> Classificazione / Output per task
    

2.2 BERT-base vs BERT-large

Il paper originale propone due configurazioni:

Configurazioni di BERT

Parametro BERT-base BERT-large
Transformer Layers (L) 12 24
Hidden Size (H) 768 1024
Attention Heads (A) 12 16
Parametri Totali 110M 340M
Dim. per Head 768/12 = 64 1024/16 = 64
Feed-Forward Dim. 3072 (4 x 768) 4096 (4 x 1024)
Max Seq. Length 512 512
Vocabulary Size 30,522 30,522

2.3 Input Representation: I Tre Embeddings

Una delle innovazioni di BERT e la sua rappresentazione dell'input, composta dalla somma di tre embeddings distinti:

I Tre Embeddings di BERT (ASCII)

  Input:         [CLS]    Il    gatto   dorme   [SEP]   Il   cane   corre   [SEP]
                   |       |      |       |       |      |     |      |       |
  Token Emb:     E[CLS]  E_il  E_gatto E_dorme E[SEP]  E_il E_cane E_corre E[SEP]
                   +       +      +       +       +      +     +      +       +
  Segment Emb:    EA      EA     EA      EA      EA     EB    EB     EB      EB
                   +       +      +       +       +      +     +      +       +
  Position Emb:   E0      E1     E2      E3      E4     E5    E6     E7      E8
                   =       =      =       =       =      =     =      =       =
  Input finale:   I0      I1     I2      I3      I4     I5    I6     I7      I8
    

Token Embeddings: Ogni token della sequenza viene mappato a un vettore denso di dimensione H (768 per BERT-base) attraverso una matrice di embedding appresa durante il training. Il vocabolario WordPiece contiene 30,522 token.

Segment Embeddings: BERT può ricevere in input una o due frasi separate dal token [SEP]. I segment embeddings indicano a quale frase appartiene ogni token: tutti i token della prima frase ricevono l'embedding E_A, quelli della seconda E_B. Questo e fondamentale per task come la Natural Language Inference dove BERT deve ragionare sulla relazione tra due frasi.

Position Embeddings: A differenza del Transformer originale che usa funzioni sinusoidali, BERT utilizza position embeddings appresi. Ogni posizione (da 0 a 511) ha un vettore appreso durante il training, il che significa che BERT può gestire sequenze fino a un massimo di 512 token.

Python - Visualizzare gli Embeddings di BERT
from transformers import BertTokenizer, BertModel
import torch

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')

# Tokenizzare un input con due frasi
text_a = "Il gatto dorme"
text_b = "Il cane corre"

encoded = tokenizer(text_a, text_b, return_tensors='pt')

print("Token IDs:", encoded['input_ids'])
print("Token Type IDs (Segments):", encoded['token_type_ids'])
print("Attention Mask:", encoded['attention_mask'])

# Decodifica per vedere i token
tokens = tokenizer.convert_ids_to_tokens(encoded['input_ids'][0])
print("Tokens:", tokens)
# Output: ['[CLS]', 'il', 'gatto', 'dor', '##me', '[SEP]', 'il', 'cane', 'cor', '##re', '[SEP]']

# Accesso agli embedding layers
token_embeddings = model.embeddings.word_embeddings
position_embeddings = model.embeddings.position_embeddings
segment_embeddings = model.embeddings.token_type_embeddings

print(f"Token embedding matrix: {token_embeddings.weight.shape}")
# Output: Token embedding matrix: torch.Size([30522, 768])
print(f"Position embedding matrix: {position_embeddings.weight.shape}")
# Output: Position embedding matrix: torch.Size([512, 768])
print(f"Segment embedding matrix: {segment_embeddings.weight.shape}")
# Output: Segment embedding matrix: torch.Size([2, 768])

2.4 Multi-Head Self-Attention

Il cuore di BERT (e di ogni Transformer) e il meccanismo di self-attention. Intuitivamente, la self-attention permette a ogni token di "guardare" tutti gli altri token nella sequenza e decidere quanto ciascuno di essi sia rilevante per la propria rappresentazione.

Scaled Dot-Product Attention

Per ogni token, l'attention calcola tre vettori attraverso proiezioni lineari apprese:

  • Query (Q): "Cosa sto cercando?"
  • Key (K): "Cosa offro?"
  • Value (V): "Quale informazione porto?"

La formula dell'attention e:

Attention(Q, K, V) = softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V

Dove d_k e la dimensione delle chiavi (64 per BERT-base). Il fattore \frac{1}{\sqrt{d_k}} serve a stabilizzare i gradienti: senza questo scaling, il prodotto scalare produce valori molto grandi per dimensionalità elevate, e il softmax si "satura" producendo distribuzioni quasi one-hot.

Multi-Head Attention

Invece di calcolare una singola funzione di attention, BERT usa multi-head attention: il calcolo viene replicato h volte (12 per BERT-base, 16 per BERT-large), ciascuno con matrici di proiezione diverse. Questo permette al modello di catturare diversi tipi di relazioni simultaneamente: una head potrebbe specializzarsi nelle relazioni sintattiche, un'altra nelle coreferenze, un'altra nelle dipendenze semantiche.

MultiHead(Q, K, V) = Concat(head_1, ..., head_h)W^O head_i = Attention(QW_i^Q, KW_i^K, VW_i^V)

Dove W_i^Q \in \mathbb{R}^{d_{model} \times d_k}, W_i^K \in \mathbb{R}^{d_{model} \times d_k}, W_i^V \in \mathbb{R}^{d_{model} \times d_v} e W^O \in \mathbb{R}^{hd_v \times d_{model}}.

Python - Calcolo Self-Attention da Zero
import torch
import torch.nn.functional as F
import math

def scaled_dot_product_attention(Q, K, V, mask=None):
    """Calcolo dell'attention scalata."""
    d_k = Q.size(-1)

    # Calcolo dei punteggi di attention
    scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)

    # Applicazione della maschera (opzionale)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float('-inf'))

    # Softmax per ottenere i pesi
    attention_weights = F.softmax(scores, dim=-1)

    # Output pesato
    output = torch.matmul(attention_weights, V)
    return output, attention_weights


class MultiHeadAttention(torch.nn.Module):
    """Multi-Head Attention come in BERT."""

    def __init__(self, d_model=768, num_heads=12):
        super().__init__()
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads  # 64 per BERT-base

        self.W_q = torch.nn.Linear(d_model, d_model)
        self.W_k = torch.nn.Linear(d_model, d_model)
        self.W_v = torch.nn.Linear(d_model, d_model)
        self.W_o = torch.nn.Linear(d_model, d_model)

    def forward(self, x, mask=None):
        batch_size, seq_len, _ = x.size()

        # Proiezioni lineari
        Q = self.W_q(x)  # (batch, seq_len, d_model)
        K = self.W_k(x)
        V = self.W_v(x)

        # Reshape per multi-head: (batch, heads, seq_len, d_k)
        Q = Q.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        K = K.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        V = V.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)

        # Attention per ogni head
        attn_output, attn_weights = scaled_dot_product_attention(Q, K, V, mask)

        # Concatenazione delle head
        attn_output = attn_output.transpose(1, 2).contiguous()
        attn_output = attn_output.view(batch_size, seq_len, self.d_model)

        # Proiezione finale
        output = self.W_o(attn_output)
        return output, attn_weights


# Test
mha = MultiHeadAttention(d_model=768, num_heads=12)
x = torch.randn(1, 10, 768)  # batch=1, seq_len=10, dim=768
output, weights = mha(x)
print(f"Output shape: {output.shape}")       # torch.Size([1, 10, 768])
print(f"Weights shape: {weights.shape}")      # torch.Size([1, 12, 10, 10])

2.5 Feed-Forward Network

Dopo ogni layer di attention, BERT applica una rete feed-forward position-wise, cioe la stessa rete viene applicata indipendentemente a ogni posizione della sequenza:

FFN(x) = \text{GELU}(xW_1 + b_1)W_2 + b_2

La dimensione intermedia e tipicamente 4 volte la dimensione nascosta (3072 per BERT-base, 4096 per BERT-large). BERT usa la funzione di attivazione GELU (Gaussian Error Linear Unit) invece della ReLU tradizionale, che fornisce una transizione più morbida e migliori performance in pratica.

2.6 Layer Normalization e Connessioni Residue

Ogni sublayer (attention e feed-forward) in BERT e avvolto da una connessione residua seguita da layer normalization:

\text{output} = \text{LayerNorm}(x + \text{Sublayer}(x))

Le connessioni residue permettono ai gradienti di fluire direttamente attraverso la rete durante il backpropagation, prevenendo il problema del vanishing gradient in reti molto profonde. La layer normalization stabilizza l'allenamento normalizzando le attivazioni lungo la dimensione delle features.

Python - Blocco Transformer Encoder Completo
import torch
import torch.nn as nn

class TransformerEncoderBlock(nn.Module):
    """Un singolo blocco encoder come in BERT."""

    def __init__(self, d_model=768, num_heads=12, d_ff=3072, dropout=0.1):
        super().__init__()
        # Multi-Head Attention
        self.attention = nn.MultiheadAttention(
            embed_dim=d_model,
            num_heads=num_heads,
            dropout=dropout,
            batch_first=True
        )
        # Feed-Forward Network
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout)
        )
        # Layer Normalization
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        # Sub-layer 1: Multi-Head Attention + Residual + LayerNorm
        attn_output, _ = self.attention(x, x, x, key_padding_mask=mask)
        x = self.norm1(x + self.dropout(attn_output))

        # Sub-layer 2: FFN + Residual + LayerNorm
        ffn_output = self.ffn(x)
        x = self.norm2(x + ffn_output)

        return x


class BERTEncoder(nn.Module):
    """Stack di encoder blocks come in BERT-base."""

    def __init__(self, num_layers=12, d_model=768, num_heads=12, d_ff=3072):
        super().__init__()
        self.layers = nn.ModuleList([
            TransformerEncoderBlock(d_model, num_heads, d_ff)
            for _ in range(num_layers)
        ])

    def forward(self, x, mask=None):
        for layer in self.layers:
            x = layer(x, mask)
        return x


# Test: simula BERT-base
encoder = BERTEncoder(num_layers=12, d_model=768, num_heads=12, d_ff=3072)
x = torch.randn(2, 128, 768)  # batch=2, seq_len=128
output = encoder(x)
print(f"Output: {output.shape}")  # torch.Size([2, 128, 768])

# Conta parametri
total_params = sum(p.numel() for p in encoder.parameters())
print(f"Parametri encoder: {total_params:,}")
# ~85M (solo encoder, senza embeddings)

3. Pre-training: Come BERT Impara il Linguaggio

BERT viene pre-addestrato su una quantità enorme di testo non etichettato utilizzando due obiettivi di training complementari: il Masked Language Model (MLM) e il Next Sentence Prediction (NSP). L'idea fondamentale e che il modello impari rappresentazioni ricche del linguaggio da queste task auto-supervisionate, e che questa conoscenza possa poi essere trasferita a task specifici tramite fine-tuning.

Dati di Pre-training

  • BooksCorpus: ~800M parole (11,000 libri non pubblicati)
  • English Wikipedia: ~2,500M parole (solo testo, no tabelle/liste)
  • Totale: ~3.3 miliardi di parole
  • Training: 4 TPU Pod (64 chip TPU), 4 giorni per BERT-base

3.1 Masked Language Model (MLM)

Il Masked Language Model e l'innovazione principale di BERT. Il problema con il language modeling tradizionale (predire la parola successiva) e che e intrinsecamente unidirezionale: il modello può guardare solo a sinistra. Per rendere il modello bidirezionale, BERT introduce un trucco elegante: mascherare una porzione casuale dei token di input e chiedere al modello di predirli.

La Strategia 80/10/10

Per ogni sequenza di input, il 15% dei token viene selezionato per la predizione. Di questi token selezionati:

  • 80% viene sostituito con il token speciale [MASK]
  • 10% viene sostituito con un token casuale dal vocabolario
  • 10% viene lasciato invariato

Questa strategia risolve un problema sottile: durante il fine-tuning, il token [MASK] non appare mai nell'input. Se il modello vedesse [MASK] solo durante il pre-training, ci sarebbe un mismatch tra pre-training e fine-tuning. Sostituendo il 10% con token casuali e lasciando il 10% invariato, il modello impara a produrre buone rappresentazioni per tutti i token, non solo per quelli mascherati.

Python - Simulazione della Strategia MLM
import random
import torch
from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

def apply_mlm_masking(tokens, tokenizer, mask_prob=0.15):
    """Applica la strategia di masking 80/10/10 di BERT."""
    masked_tokens = list(tokens)
    labels = [-100] * len(tokens)  # -100 = ignora nella loss

    for i in range(len(tokens)):
        # Non mascherare token speciali
        if tokens[i] in [tokenizer.cls_token_id, tokenizer.sep_token_id,
                         tokenizer.pad_token_id]:
            continue

        if random.random() < mask_prob:
            labels[i] = tokens[i]  # Salva il token originale come label

            rand = random.random()
            if rand < 0.8:
                # 80%: sostituisci con [MASK]
                masked_tokens[i] = tokenizer.mask_token_id
            elif rand < 0.9:
                # 10%: sostituisci con token casuale
                masked_tokens[i] = random.randint(0, tokenizer.vocab_size - 1)
            # else: 10%: lascia invariato

    return masked_tokens, labels


# Esempio
text = "Il gatto si siede sul tappeto rosso"
encoded = tokenizer.encode(text)
print("Originale:", tokenizer.decode(encoded))

masked, labels = apply_mlm_masking(encoded, tokenizer)
print("Mascherato:", tokenizer.decode(masked))
print("Labels (posizioni da predire):",
      [(i, tokenizer.decode([labels[i]])) for i in range(len(labels)) if labels[i] != -100])

3.2 Next Sentence Prediction (NSP)

Il secondo obiettivo di pre-training e il Next Sentence Prediction. Per ogni coppia di frasi (A, B) nell'input, il modello deve predire se B e la frase che segue effettivamente A nel testo originale (IsNext) oppure se e una frase casuale (NotNext). Il dataset e costruito in modo che il 50% delle coppie sia positivo e il 50% negativo.

Esempio di NSP
Input = [CLS] Il gatto dorme [SEP] È stanco dopo aver giocato [SEP]
Label = IsNext (frase B segue A nel testo originale)

Input = [CLS] Il gatto dorme [SEP] Roma è la capitale d'Italia [SEP]
Label = NotNext (frase B è casuale, non correlata ad A)

L'obiettivo NSP aiuta BERT a catturare relazioni tra frasi, utili per task come Question Answering e Natural Language Inference. Tuttavia, studi successivi (in particolare RoBERTa) hanno dimostrato che NSP potrebbe non essere necessario e potrebbe persino danneggiare le performance, come vedremo nella sezione sulle varianti.

3.3 La Loss Combinata

La loss totale di BERT durante il pre-training e la somma delle due loss:

\mathcal{L}_{total} = \mathcal{L}_{MLM} + \mathcal{L}_{NSP}

Dove \mathcal{L}_{MLM} e la cross-entropy loss sui token mascherati e \mathcal{L}_{NSP} e la cross-entropy loss binaria sulla classificazione IsNext/NotNext.

4. Tokenizzazione WordPiece

BERT utilizza la tokenizzazione WordPiece, un algoritmo di tokenizzazione subword che bilancia efficienza del vocabolario e copertura linguistica. WordPiece e stato originariamente sviluppato per il sistema di traduzione automatica di Google.

4.1 Come Funziona WordPiece

L'idea alla base di WordPiece e semplice ma potente: partire da un vocabolario di singoli caratteri e iterativamente unire le coppie di token che massimizzano la verosimiglianza del corpus di training. Il processo continua finchè il vocabolario non raggiunge una dimensione target (30,522 per BERT).

Il risultato e un vocabolario che contiene:

  • Parole comuni intere (es. "the", "of", "and")
  • Prefissi e radici comuni (es. "un", "re", "pre")
  • Suffissi e desinenze marcati con ## (es. "##ing", "##tion", "##ed")
  • Singoli caratteri per gestire qualsiasi parola sconosciuta

4.2 Token Speciali

Oltre ai token del vocabolario WordPiece, BERT utilizza diversi token speciali:

Token Speciali di BERT

Token ID Scopo
[PAD] 0 Padding per uniformare la lunghezza delle sequenze in un batch
[UNK] 100 Token sconosciuto (non nel vocabolario)
[CLS] 101 Inizio sequenza, la sua rappresentazione finale e usata per la classificazione
[SEP] 102 Separatore tra le due frasi di input
[MASK] 103 Maschera per il Masked Language Model durante il pre-training
Python - Tokenizzazione WordPiece in Pratica
from transformers import BertTokenizer

# Carica il tokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

# Tokenizzazione di parole comuni e rare
examples = [
    "The cat sat on the mat",
    "Electroencephalography is fascinating",
    "L'intelligenza artificiale rivoluzionera il mondo",
    "Tokenizzazione subword con WordPiece"
]

for text in examples:
    tokens = tokenizer.tokenize(text)
    ids = tokenizer.encode(text)
    print(f"Testo: {text}")
    print(f"  Tokens: {tokens}")
    print(f"  IDs: {ids}")
    print(f"  Num tokens: {len(tokens)}")
    print()

# Output per "Electroencephalography is fascinating":
# Tokens: ['electro', '##ence', '##pha', '##log', '##raphy', 'is', 'fascinating']
# La parola lunga viene spezzata in subword, ma "is" e "fascinating" restano intere

# Decodifica
encoded = tokenizer("Hello world!", return_tensors="pt")
decoded = tokenizer.decode(encoded['input_ids'][0])
print(f"Encoded -> Decoded: {decoded}")
# Output: [CLS] hello world ! [SEP]

# Vocabolario
print(f"Dimensione vocabolario: {tokenizer.vocab_size}")
# Output: 30522

5. Fine-tuning BERT per Task Downstream

Il paradigma introdotto da BERT e "pre-train, then fine-tune". La fase di pre-training produce un modello con una comprensione profonda del linguaggio. La fase di fine-tuning adatta questo modello a un task specifico aggiungendo uno strato di output e allenando l'intero modello (o parte di esso) sui dati etichettati del task.

Il bello del fine-tuning e che richiede pochi dati etichettati e poco tempo di training rispetto ad allenare un modello da zero. Tipicamente, 2-4 epoche con un learning rate basso (2e-5 a 5e-5) sono sufficienti.

5.1 Text Classification

Per la classificazione del testo (sentiment analysis, topic classification, spam detection), si usa la rappresentazione del token [CLS] come input per un classificatore lineare:

Schema: Fine-tuning per Classificazione

  Input:  [CLS] Questo film è fantastico [SEP]
            |
  BERT: 12 layers di Transformer Encoder
            |
  [CLS] output (768-dim) --> Linear(768, num_classes) --> Softmax --> Predizione
                                                                        |
                                                              "Positivo" (p=0.95)
    
Python - Fine-tuning per Sentiment Analysis
from transformers import BertForSequenceClassification, BertTokenizer
from transformers import Trainer, TrainingArguments
from datasets import load_dataset
import torch

# 1. Carica modello pre-addestrato con classification head
model = BertForSequenceClassification.from_pretrained(
    'bert-base-uncased',
    num_labels=2  # positivo/negativo
)
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

# 2. Prepara il dataset (esempio: IMDB reviews)
dataset = load_dataset('imdb')

def tokenize_function(examples):
    return tokenizer(
        examples['text'],
        padding='max_length',
        truncation=True,
        max_length=256
    )

tokenized_datasets = dataset.map(tokenize_function, batched=True)

# 3. Configura il training
training_args = TrainingArguments(
    output_dir='./results',
    num_train_epochs=3,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=64,
    warmup_steps=500,
    weight_decay=0.01,
    learning_rate=2e-5,
    logging_dir='./logs',
    evaluation_strategy='epoch',
    save_strategy='epoch',
    load_best_model_at_end=True,
)

# 4. Crea il Trainer e avvia il fine-tuning
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_datasets['train'],
    eval_dataset=tokenized_datasets['test'],
)

trainer.train()

# 5. Valutazione
results = trainer.evaluate()
print(f"Accuracy: {results['eval_loss']:.4f}")

5.2 Named Entity Recognition (NER)

Per il NER, invece di usare solo il token [CLS], si usa l'output di ogni token per classificare ciascuno in una categoria di entità (persona, luogo, organizzazione, ecc.):

Schema: Fine-tuning per NER

  Input:    [CLS]  Mario   Rossi    vive     a     Roma    [SEP]
              |      |       |        |      |       |       |
  BERT:   12 layers di Transformer Encoder
              |      |       |        |      |       |       |
  Output:     O    B-PER   I-PER      O      O    B-LOC      O

  Ogni token --> Linear(768, num_entity_types) --> Softmax --> Tipo entità
    
Python - Fine-tuning per NER
from transformers import BertForTokenClassification, BertTokenizer
from transformers import pipeline

# Usa un modello già fine-tuned per NER
ner_pipeline = pipeline(
    "ner",
    model="dbmdz/bert-large-cased-finetuned-conll03-english",
    aggregation_strategy="simple"
)

text = "Mario Rossi works at Google in Mountain View, California"
entities = ner_pipeline(text)

for entity in entities:
    print(f"  {entity['word']}: {entity['entity_group']} "
          f"(score: {entity['score']:.3f})")
# Output:
#   Mario Rossi: PER (score: 0.998)
#   Google: ORG (score: 0.997)
#   Mountain View: LOC (score: 0.995)
#   California: LOC (score: 0.999)

5.3 Question Answering

Per l'extractive question answering, BERT riceve in input una domanda e un contesto separati da [SEP]. Il modello deve predire le posizioni di inizio e fine della risposta all'interno del contesto:

Schema: Fine-tuning per Question Answering

  Input: [CLS] Quando è nato Einstein? [SEP] Albert Einstein è nato il 14 marzo 1879 [SEP]
           |      |     |    |      |      |     |      |       |    |    |    |     |     |
  BERT: 12 layers di Transformer Encoder
           |      |     |    |      |      |     |      |       |    |    |    |     |     |
  Start:   0.01   0.01  0.01 0.01  0.01   0.01  0.01   0.01  0.02  0.8  0.02 0.01  0.01  0.01
  End:     0.01   0.01  0.01 0.01  0.01   0.01  0.01   0.01  0.01  0.01 0.01 0.01  0.85  0.01

  Risposta estratta: "14 marzo 1879"
    
Python - Question Answering con BERT
from transformers import pipeline

# Pipeline di Question Answering
qa_pipeline = pipeline(
    "question-answering",
    model="deepset/bert-base-cased-squad2"
)

context = """
BERT (Bidirectional Encoder Representations from Transformers)
è un modello di linguaggio sviluppato da Google AI nel 2018.
È stato addestrato su Wikipedia e BookCorpus, per un totale
di circa 3.3 miliardi di parole. BERT-base ha 110 milioni di
parametri e 12 layer di Transformer encoder.
"""

questions = [
    "Chi ha sviluppato BERT?",
    "Quanti parametri ha BERT-base?",
    "Su quali dati è stato addestrato BERT?",
    "In che anno è stato pubblicato BERT?"
]

for question in questions:
    result = qa_pipeline(question=question, context=context)
    print(f"D: {question}")
    print(f"R: {result['answer']} (score: {result['score']:.3f})")
    print()

5.4 Sentence Pair Classification

Per task come il Natural Language Inference (NLI) o il paraphrase detection, BERT riceve due frasi e deve classificare la loro relazione (entailment, contradiction, neutral):

Python - Sentence Pair Classification (NLI)
from transformers import pipeline

# Natural Language Inference
nli_pipeline = pipeline(
    "text-classification",
    model="cross-encoder/nli-deberta-v3-small"
)

pairs = [
    ("Il gatto dorme sul divano", "Un animale sta riposando"),
    ("Il gatto dorme sul divano", "Il cane corre nel parco"),
    ("Tutti gli studenti hanno superato l'esame", "Nessuno studente ha fallito"),
]

for premise, hypothesis in pairs:
    result = nli_pipeline(f"{premise} [SEP] {hypothesis}")
    print(f"Premessa: {premise}")
    print(f"Ipotesi: {hypothesis}")
    print(f"Risultato: {result[0]['label']} ({result[0]['score']:.3f})")
    print()

6. Estrarre Embeddings da BERT

Oltre al fine-tuning per task specifici, BERT e estremamente utile come feature extractor. Le rappresentazioni prodotte dai layer interni catturano informazioni linguistiche a diversi livelli di astrazione.

Python - Estrazione di Embeddings dai Layer di BERT
from transformers import BertTokenizer, BertModel
import torch

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased', output_hidden_states=True)
model.eval()

text = "Il Natural Language Processing è affascinante"
inputs = tokenizer(text, return_tensors='pt')

with torch.no_grad():
    outputs = model(**inputs)

# outputs contiene:
# - last_hidden_state: output dell'ultimo layer (batch, seq_len, 768)
# - pooler_output: [CLS] passato attraverso un linear + tanh (batch, 768)
# - hidden_states: tuple di 13 tensori (embedding + 12 layers)

last_hidden = outputs.last_hidden_state
pooler = outputs.pooler_output
all_layers = outputs.hidden_states

print(f"Last hidden state: {last_hidden.shape}")
# torch.Size([1, N, 768])
print(f"Pooler output: {pooler.shape}")
# torch.Size([1, 768])
print(f"Numero di layer: {len(all_layers)}")
# 13 (embedding layer + 12 transformer layers)

# Strategie per ottenere sentence embeddings:

# 1. Usa il [CLS] token dell'ultimo layer
cls_embedding = last_hidden[:, 0, :]
print(f"[CLS] embedding: {cls_embedding.shape}")

# 2. Mean pooling sull'ultimo layer (spesso migliore)
attention_mask = inputs['attention_mask'].unsqueeze(-1)
mean_embedding = (last_hidden * attention_mask).sum(1) / attention_mask.sum(1)
print(f"Mean pooling: {mean_embedding.shape}")

# 3. Concatena gli ultimi 4 layer (cattura più informazione)
last_4_layers = torch.cat(
    [all_layers[i] for i in [-1, -2, -3, -4]],
    dim=-1
)
print(f"Last 4 layers concatenated: {last_4_layers.shape}")
# torch.Size([1, N, 3072])

Quale Layer Usare?

La ricerca ha mostrato che diversi layer catturano diversi tipi di informazione:

  • Layer 1-4 (bassi): Informazioni morfologiche e sintattiche di base
  • Layer 5-8 (medi): Informazioni sintattiche complesse, dipendenze
  • Layer 9-12 (alti): Informazioni semantiche di alto livello
  • Ultimo layer: Migliore per task semantici (NLI, similarità)
  • Layer medi: Migliori per task sintattici (POS tagging, parsing)

7. Varianti di BERT: L'Evoluzione della Famiglia

Dopo la pubblicazione di BERT, numerosi gruppi di ricerca hanno proposto varianti che ne migliorano diversi aspetti. Ecco una panoramica delle più importanti.

Tabella Comparativa delle Varianti di BERT

Modello Anno Innovazione Principale Parametri
BERT-base 2018 MLM + NSP, bidirezionale profondo 110M
RoBERTa 2019 No NSP, dynamic masking, più dati, training più lungo 125M
ALBERT 2019 Parameter sharing, factorized embedding 12M-235M
DistilBERT 2019 Knowledge distillation, 60% più veloce 66M
ELECTRA 2020 Replaced token detection (più efficiente di MLM) 110M
DeBERTa 2020 Disentangled attention (contenuto + posizione separati) 134M-390M
XLNet 2019 Permutation language modeling 340M
SpanBERT 2019 Masking di span contigui + SBO 110M
ERNIE 2019 Knowledge-enhanced, entity masking 110M

7.1 RoBERTa: Ottimizzare il Training di BERT

RoBERTa (Robustly optimized BERT approach), proposto da Facebook AI (ora Meta), ha dimostrato che BERT era stato sotto-addestrato. Le modifiche principali sono:

  • Rimozione di NSP: L'obiettivo NSP non aiuta e può persino peggiorare le performance
  • Dynamic masking: Invece di mascherare una volta sola durante il preprocessing, i pattern di masking vengono generati dinamicamente ad ogni epoca
  • Più dati: 160GB di testo (vs 16GB di BERT), inclusi CC-News, OpenWebText, Stories
  • Training più lungo: 500K step (vs 100K di BERT), batch size maggiore
  • Sequenze più lunghe: Sempre 512 token (BERT iniziava con 128)

7.2 ALBERT: Compressione Intelligente

ALBERT (A Lite BERT) di Google affronta il problema della crescita dei parametri con due tecniche eleganti:

  • Factorized embedding parameterization: Separa la dimensione del vocabolario dalla dimensione nascosta. Invece di una matrice V x H, usa V x E e E x H (con E molto minore di H)
  • Cross-layer parameter sharing: Tutti i layer del Transformer condividono gli stessi parametri. Questo riduce drasticamente il numero di parametri senza perdere molta qualità

ALBERT-xxlarge raggiunge performance superiori a BERT-large con solo 235M parametri (vs 340M), anche se il tempo di inferenza non diminuisce poichè il numero di layer resta invariato.

7.3 DistilBERT: Distillazione della Conoscenza

DistilBERT di Hugging Face usa la knowledge distillation per creare un modello più piccolo e veloce. Un modello "studente" (6 layer) viene addestrato a imitare le output di un modello "insegnante" (BERT-base, 12 layer).

  • 40% più piccolo: 66M parametri vs 110M
  • 60% più veloce: 6 layer vs 12
  • 97% delle performance: Mantiene quasi tutta la qualità di BERT-base

7.4 ELECTRA: Un Approccio Completamente Diverso

ELECTRA sostituisce il Masked Language Model con un approccio ispirato alle GAN (Generative Adversarial Networks). Invece di mascherare token e predirli, ELECTRA:

  1. Un piccolo generatore (un BERT piccolo) produce token sostitutivi plausibili
  2. Il discriminatore (il modello principale) deve identificare quali token sono stati sostituiti (replaced token detection)

Il vantaggio chiave: il discriminatore impara da tutti i token della sequenza, non solo dal 15% mascherato come in BERT. Questo rende ELECTRA molto più efficiente in termini di dati e compute.

7.5 DeBERTa: Attenzione Disaccoppiata

DeBERTa (Decoding-enhanced BERT with disentangled Attention) di Microsoft introduce due innovazioni:

  • Disentangled attention: Separa le informazioni di contenuto e posizione in due vettori distinti, permettendo al modello di ragionare su "cosa" e "dove" indipendentemente
  • Enhanced mask decoder: Aggiunge informazioni di posizione assoluta nel decoder per migliorare le predizioni MLM

DeBERTa v3 e attualmente uno dei modelli encoder più performanti su molti benchmark, spesso superando modelli molto più grandi.

8. BERT per la Lingua Italiana

Il BERT originale e stato addestrato su testo inglese. Per l'italiano, abbiamo diverse opzioni, ciascuna con vantaggi e svantaggi.

Modelli BERT per l'Italiano

Modello Base Dati di Training Vocabolario
mBERT BERT-base Wikipedia in 104 lingue 110K token multilingue
dbmdz/bert-base-italian-xxl-cased BERT-base OPUS, Wikipedia IT (~13GB) 30K token italiani
AlBERTo BERT-base Tweet in italiano 30K token italiani
UmBERTo RoBERTa OSCAR Italian corpus 32K token SentencePiece
XLM-RoBERTa RoBERTa CC-100 in 100 lingue 250K token multilingue
Python - Confronto Modelli Italiani
from transformers import pipeline, AutoTokenizer, AutoModelForMaskedLM

# 1. BERT Italiano (dbmdz)
fill_mask_it = pipeline(
    "fill-mask",
    model="dbmdz/bert-base-italian-xxl-cased"
)

results = fill_mask_it("Roma è la [MASK] d'Italia.")
for r in results[:3]:
    print(f"  {r['token_str']}: {r['score']:.4f}")
# Output: capitale: 0.8234, citta: 0.0521, ...

# 2. UmBERTo (basato su RoBERTa)
fill_mask_umberto = pipeline(
    "fill-mask",
    model="Musixmatch/umberto-commoncrawl-cased-v1"
)

results = fill_mask_umberto("L'intelligenza artificiale <mask> il futuro.")
for r in results[:3]:
    print(f"  {r['token_str']}: {r['score']:.4f}")

# 3. Multilingual BERT
fill_mask_mbert = pipeline(
    "fill-mask",
    model="bert-base-multilingual-cased"
)

results = fill_mask_mbert("Roma è la [MASK] d'Italia.")
for r in results[:3]:
    print(f"  {r['token_str']}: {r['score']:.4f}")
# mBERT funziona, ma il modello italiano dedicato è molto più' preciso

Quale Modello Scegliere per l'Italiano?

  • Task generali (NER, classificazione, QA): dbmdz/bert-base-italian-xxl-cased oppure UmBERTo
  • Analisi social media: AlBERTo (addestrato su tweet)
  • Task multilingue: XLM-RoBERTa (ottimo per zero-shot cross-lingual)
  • Massima qualità: Fine-tune di XLM-RoBERTa-large su dati italiani

9. Limiti di BERT

Nonostante il suo impatto rivoluzionario, BERT presenta diversi limiti strutturali che ne condizionano l'applicabilita:

9.1 Limite dei 512 Token

BERT può processare sequenze di massimo 512 token. Per documenti lunghi (articoli, contratti legali, paper scientifici), questo e un limite severo. Strategie di mitigazione includono il truncation, lo sliding window e la hierarchical pooling, ma nessuna e ottimale.

9.2 Complessità Quadratica dell'Attention

La self-attention ha complessità computazionale O(n^2) rispetto alla lunghezza della sequenza n. Questo significa che raddoppiare la lunghezza dell'input quadruplica il costo computazionale e il consumo di memoria. Modelli come Longformer, BigBird e Linformer propongono meccanismi di attention sparsa per aggirare questo problema.

9.3 Solo Encoder: Nessuna capacità Generativa

BERT e un modello encoder-only. Può produrre rappresentazioni ricche del testo, ma non può generare nuovo testo. Per la generazione serve un decoder (come in GPT) o un'architettura encoder-decoder (come in T5 o BART).

9.4 Mismatch Pre-training/Fine-tuning

Il token [MASK] appare durante il pre-training ma mai durante il fine-tuning o l'inferenza. Anche se la strategia 80/10/10 mitiga parzialmente il problema, questo mismatch rimane una debolezza teorica. ELECTRA risolve completamente il problema non usando mai [MASK].

9.5 Indipendenza dei Token Mascherati

Quando BERT maschera più token, li predice in modo indipendente. Non modella le dipendenze tra i token mascherati. Ad esempio, se la frase e "New [MASK] [MASK]" e la risposta e "New York City", BERT predice "York" e "City" indipendentemente, senza condizionare la predizione di uno sull'altro. XLNet affronta questo problema con il permutation language modeling.

Riepilogo Limiti e Soluzioni

Limite Impatto Soluzione
Max 512 token No documenti lunghi Longformer, BigBird, sliding window
Attention O(n^2) Costi computazionali elevati Linformer, Performer, Flash Attention
Solo encoder No generazione testo T5, BART, GPT
[MASK] mismatch Discrepanza train/inference ELECTRA, XLNet
Predizioni indipendenti No dipendenze tra token mascherati XLNet, autoregressive models

10. Da BERT ai Modelli Moderni

BERT ha inaugurato l'era del "pre-train then fine-tune" nell'NLP, influenzando profondamente tutte le architetture successive. Vediamo come si e evoluto il panorama.

10.1 L'Albero Genealogico dei Modelli

Evoluzione dei Modelli NLP (ASCII)

  Transformer (2017)
     |
     +------ Encoder-Only ------+------ Decoder-Only ------+--- Encoder-Decoder ---+
     |                          |                           |                       |
   BERT (2018)              GPT-1 (2018)                  T5 (2019)              BART (2019)
     |                          |                           |
     +-- RoBERTa (2019)     GPT-2 (2019)                mT5 (2020)
     +-- ALBERT (2019)          |
     +-- DistilBERT (2019)  GPT-3 (2020)
     +-- XLNet (2019)           |
     +-- ELECTRA (2020)     GPT-4 (2023)
     +-- DeBERTa (2020)         |
     +-- DeBERTa v3 (2021)  LLaMA (2023)
                                |
                            LLaMA 2 (2023)
                                |
                            Mistral (2023)
                                |
                            LLaMA 3 (2024)
    

10.2 La Lezione Chiave di BERT

L'eredita più importante di BERT non e un'architettura specifica, ma un paradigma: addestra un modello su enormi quantità di testo non etichettato usando obiettivi auto-supervisionati, e poi adattalo a task specifici con pochi dati etichettati. Questo paradigma e alla base di tutti i modelli moderni, dai GPT ai LLaMA.

10.3 Dove BERT e Ancora Rilevante

Nonostante l'emergere di modelli generativi molto più grandi, BERT e i suoi discendenti encoder-only rimangono la scelta migliore per molti scenari:

  • Classificazione del testo: Sentiment, topic, spam detection
  • Named Entity Recognition: Estrazione di entità strutturate
  • Semantic search: Ricerca basata su significato (con sentence-transformers)
  • Information retrieval: Ranking di documenti rilevanti
  • Embedding generation: Rappresentazioni dense per il testo

I modelli encoder come BERT sono più efficienti, più veloci e meno costosi rispetto ai grandi modelli generativi. Per molti task di comprensione del linguaggio, un DeBERTa-v3 fine-tuned supera modelli con 100 volte più parametri.

11. Conclusioni e Quando Usare BERT

In questo articolo abbiamo analizzato BERT in profondità: dall'architettura encoder-only del Transformer, al meccanismo di multi-head self-attention, alle strategie di pre-training (MLM e NSP), fino alle implementazioni pratiche con HuggingFace e alle varianti che ne sono derivate.

Decision Framework: Quando Usare BERT

Scenario Modello Consigliato Motivazione
Classificazione testo (produzione) DeBERTa-v3 o RoBERTa Massima accuratezza, costo ragionevole
NER (italiano) dbmdz BERT IT o UmBERTo Vocabolario nativo italiano
Risorse limitate / edge DistilBERT o TinyBERT Veloce, compatto, 97% qualità
Efficienza di training ELECTRA Impara da tutti i token, non solo 15%
Generazione di testo GPT / T5 / LLaMA BERT non può generare
Documenti lunghi Longformer / BigBird BERT limitato a 512 token
Multilingue / zero-shot XLM-RoBERTa Eccellente transfer cross-linguale

BERT resta un pilastro dell'NLP moderno. Anche se i Large Language Models generativi dominano i titoli dei giornali, i modelli encoder come BERT sono la spina dorsale di innumerevoli applicazioni in produzione: motori di ricerca, sistemi di raccomandazione, filtri anti-spam, assistenti legali, analisi del sentiment e molto altro.

Nel prossimo articolo della serie, metteremo in pratica queste conoscenze costruendo un sistema di Sentiment Analysis completo per il testo italiano, dalla preparazione del dataset al deployment del modello.

Risorse per Approfondire

  • Paper originale: "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding" (Devlin et al., 2018)
  • Paper RoBERTa: "A Robustly Optimized BERT Pretraining Approach" (Liu et al., 2019)
  • Paper ELECTRA: "ELECTRA: Pre-training Text Encoders as Discriminators Rather Than Generators" (Clark et al., 2020)
  • Paper DeBERTa: "DeBERTa: Decoding-enhanced BERT with Disentangled Attention" (He et al., 2020)
  • HuggingFace BERT documentation: huggingface.co/docs/transformers/model_doc/bert
  • The Illustrated BERT: jalammar.github.io/illustrated-bert/

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