Costruzione di un Classificatore di Titoli di Notizie con AWS SageMaker

Panoramica del Progetto

Questo caso di studio descrive lo sviluppo e la distribuzione di un sistema di classificazione multiclasse per titoli di notizie utilizzando AWS SageMaker, i Transformers di Hugging Face e PyTorch. Il sistema categorizza automaticamente i titoli di notizie in una delle quattro categorie: Business, Scienza, Intrattenimento o Salute.

Obiettivi del Progetto

  • Costruire un classificatore di titoli di notizie ad alta precisione
  • Implementare un'architettura personalizzata basata su transformer
  • Distribuire il modello in un ambiente di produzione scalabile
  • Creare un'API RESTful per l'inferenza in tempo reale

Tecnologie Utilizzate

  • AWS SageMaker: Per l'addestramento e l'hosting del modello
  • PyTorch: Per lo sviluppo del modello
  • Hugging Face Transformers: Per modelli linguistici pre-addestrati (DistilBERT)
  • AWS Lambda & API Gateway: Per la distribuzione di API serverless
  • CloudWatch: Per il monitoraggio e la registrazione
  • Postman: Per il test dell'API

Raccolta e Preparazione dei Dati

Il progetto ha utilizzato un dataset di aggregazione di notizie dal repository UCI Machine Learning contenente oltre 400.000 titoli di notizie da varie fonti. Link del dataset

Fasi di Elaborazione dei Dati:

  1. Caricamento dei dati dal bucket S3 utilizzando pandas
  2. Estrazione dei campi 'TITLE' e 'CATEGORY'
  3. Trasformazione delle etichette di categoria da abbreviazioni (es. 'b') a nomi completi (es. 'Business')
  4. Codifica delle categorie come valori numerici
  5. Suddivisione dei dati in 80% per il training e 20% per il testing
my_dict = {
    'e': 'Entertainment',
    'b': 'Business',
    't': 'Science',
    'm': 'Health'
}
def update_category(x):
    return my_dict[x]


df_work['CATEGORY'] = df_work['CATEGORY'].apply(lambda x: update_category(x))
# Codifica delle categorie come valori numerici</em>
encode_dict = {}
def encode_cat(x):
    if x not in encode_dict.keys():
        encode_dict[x] = len(encode_dict)
    return encode_dict[x]

df['ENCODE_CAT'] = df['CATEGORY'].apply(lambda x: encode_cat(x))Code language: PHP (php)
Dataset prima e dopo l'encoding delle categorie

Analisi Esplorativa dei Dati

L'analisi del dataset ha rivelato la seguente distribuzione delle categorie:

  • Business: ~27,5%
  • Scienza: ~25,6%
  • Intrattenimento: ~36,1%
  • Salute: ~10,8%

Questa distribuzione relativamente bilanciata ha significato che non era necessario un trattamento speciale per lo sbilanciamento delle classi.

Architettura del Modello

Ho scelto di personalizzare un modello DistilBERT, che offre un buon equilibrio tra prestazioni ed efficienza. DistilBERT è una versione più piccola e veloce di BERT che mantiene circa il 97% delle sue capacità di comprensione del linguaggio pur essendo il 40% più piccola e il 60% più veloce.

Personalizzazione del Modello:

  1. Utilizzo di DistilBERT pre-addestrato come estrattore di feature
  2. Aggiunta di un layer lineare pre-classificatore (768 → 768 dimensioni)
  3. Implementazione di dropout (0.3) per la regolarizzazione
  4. Aggiunta di una funzione di attivazione ReLU per la non linearità
  5. Aggiunta di un layer di classificazione finale (768 → 4 dimensioni, una per ogni categoria)
# Customized model based on the imported model
class DistilBERTClass(torch.nn.Module):
    def __init__(self):
        # Definire sempre layer e componenti con parametri trainabili 
        # sempre dentro il metodo init, altrimenti i paremtri verrebbero
        # inizializzati ogni volta
        # super().__init__()
        super(DistilBERTClass, self).__init__()
        # load pretrained model for transfer learning
        self.l1 = DistilBertModel.from_pretrained('distilbert-base-uncased')
        # additional weights
        self.pre_classifier = torch.nn.Linear(768, 768)
        # random drop 30% of neurons
        self.dropout = torch.nn.Dropout(0.3)
        # map out final vector into 1 of our 4 classes
        self.classifier = torch.nn.Linear(768, 4)

    def forward(self, input_ids, attention_mask):
        # qui chiamiamo quello definito sopra
        output_1 = self.l1(input_ids=input_ids, attention_mask=attention_mask)
        # final output del transformer layer in DistilBert
        hidden_state = output_1[0]
        # extract first special token CLS
        pooler = hidden_state[:, 0]
        pooler = self.pre_classifier(pooler)
        # Activation function ReLU, aggiunge non linearità
        pooler = torch.nn.ReLU()(pooler)
        pooler = self.dropout(pooler)
        output = self.classifier(pooler)
        # RAW LOGITS
        return output

Processo di Training

Il modello è stato addestrato su AWS SageMaker utilizzando un'istanza GPU (ml.g4dn.xlarge) per un addestramento accelerato.

Configurazione dell'Addestramento:

  • Ottimizzatore: Adam con learning rate di 1e-5
  • Funzione di Loss: Cross-Entropy Loss
  • Dimensione del Batch: 4 per il training, 2 per la validazione
  • Epoche: 2 (sufficienti grazie al transfer learning)
  • Lunghezza Massima della Sequenza: 512 token
def train(epoch, model, device, training_loader, optimizer, loss_function):
    tr_loss = 0
    n_correct = 0
    nb_tr_steps = 0
    nb_tr_examples = 0
    model.train()

    for _, data in enumerate(training_loader, 0):
        ids = data['ids'].to(device, dtype=torch.long)
        mask = data['mask'].to(device, dtype=torch.long)
        targets = data['targets'].to(device, dtype=torch.long)

        # chiamiamo il forward method della classe
        outputs = model(ids, mask)

        loss = loss_function(outputs, targets)
        tr_loss += loss.item()
        big_val, big_idx = torch.max(outputs.data, dim=1)
        n_correct += calculate_accu(big_idx, targets)

        nb_tr_steps += 1
        nb_tr_examples += targets.size(0)

        # 100k / 4 = 25,000 steps
        if _ % 5000 == 0:
            loss_step = tr_loss/nb_tr_steps
            accu_step = (n_correct*100)/nb_tr_examples
            print(f"Training loss per 5000 steps: {loss_step}")
            print(f"Training Accuracy per 5000 steps: {accu_step}")

        # gradient descent and backpropagation
        # Dobbiamo impostare a 0, perchè in pyThorc accumulano di default
        optimizer.zero_grad()

        # BACK propagation starting from the loss
        loss.backward()
        # agggiorna i pesi
        optimizer.step()

    # print(f"The total accuracy for epoch {epoch}: {(n_correct*100)/nb_tr_examples}")
    epoch_loss = tr_loss / nb_tr_steps
    epoch_accu = (n_correct*100)/nb_tr_examples
    print(f"Training Loss Epoch:{epoch_loss} ")
    print(f"Training Accuracy Epoch {epoch_accu}")

    returnCode language: PHP (php)

Gestione del Padding e Attention Mask

Un aspetto cruciale della configurazione dell'addestramento, spesso trascurato nelle discussioni, è il trattamento del padding e l'uso dell'attention mask nei modelli transformer.

Padding delle Sequenze

Poiché i titoli di notizie hanno lunghezze variabili, ma le reti neurali richiedono input di dimensione fissa, ho utilizzato il padding per uniformare la lunghezza:

# Estratto dalla classe NewsDataSet
inputs = self.tokenizer.encode_plus(
            title,
            None,
            add_special_tokens=True,
            max_length=self.max_len,
            padding='max_length',
            return_token_type_ids=True,
            return_attention_mask=True,
            truncations=True,
        )Code language: PHP (php)

Questo approccio:

  • Aggiunge token di padding [PAD] (ID=0) fino a raggiungere la lunghezza massima di 512 token
  • Standardizza la dimensione degli input per l'elaborazione in batch
  • Evita sprechi di risorse computazionali impostando una lunghezza massima ragionevole

Attention Mask: Ignorare il Padding

Il vero potere di questo approccio emerge con l'attention mask, un tensore binario che indica al modello quali token considerare (1) e quali ignorare (0):

ids = inputs['input_ids'] # Token IDs inclusi i padding
mask = inputs['attention_mask'] # 1 per token reali, 0 per paddingCode language: PHP (php)

Quando questi tensori vengono passati al modello:

outputs = model(ids, mask)

L'attention mask viene utilizzato internamente per modificare gli attention scores:

  1. Per ogni position con valore di mask=0 (padding), i punteggi di attenzione vengono impostati a -inf prima dell'applicazione del softmax
  2. Dopo l'applicazione del softmax, questi punteggi diventano effettivamente 0
  3. Di conseguenza, i token di padding non contribuiscono al calcolo dell'output

Questo meccanismo è fondamentale perché:

  • Previene che il modello "sprechi attenzione" sui token di padding
  • Mantiene la stabilità numerica dell'addestramento
  • Consente l'elaborazione efficiente di batch con sequenze di lunghezze diverse
  • Migliora la qualità delle rappresentazioni contestuali per i token reali

Nella nostra implementazione, questo meccanismo ha permesso di gestire efficacemente titoli di notizie che variavano da poche parole a decine di parole, mantenendo la coerenza dell'addestramento e migliorando la qualità delle predizioni, soprattutto per titoli più complessi e lunghi.

La combinazione di padding controllato e attention mask ha contribuito significativamente all'alto tasso di accuratezza ottenuto (~91.7% in validazione) nonostante la variabilità della lunghezza dei titoli nel dataset.

Risultati di Training e Validazione:

  • Accuratezza di training: ~93.7%
  • Accuratezza di validazione: ~91.7%

Il modello ha mostrato una buona convergenza dopo solo 2 epoche, indicando un apprendimento efficace senza overfitting.

Funzione di Loss: Cross Entropy

Per la formazione del nostro modello di classificazione delle notizie, ho utilizzato la funzione di loss Cross Entropy, che è particolarmente adatta per problemi di classificazione multiclasse.

Matematica dietro la Cross Entropy Loss

La Cross Entropy Loss è definita come:

\(\text{Loss}= \sum_{c=1}^Cy_{c}\log(\hat{y}_{c})\)

Dove:

  • \(y_c\) è l'etichetta vera per la classe \(c\) (1 per la classe corretta, 0 per le altre)
  • \(\hat{y}_c\) è la probabilità predetta per la classe \(c\) (output del softmax)
  • C è il numero totale di classi (4 nel nostro caso: Business, Science, Entertainment, Health)

In PyTorch, questo viene implementato attraverso la classe nn.CrossEntropyLoss(), che combina:

  1. nn.LogSoftmax: Applica il logaritmo dopo la normalizzazione softmax
  2. nn.NLLLoss: Applica la loss di entropia negativa
# Definizione della funzione di loss
loss_function = torch.nn.CrossEntropyLoss()

# Durante il training
outputs = model(ids, mask) # Output raw (logits)

loss = loss_function(outputs, targets) # Calcolo della lossCode language: PHP (php)

Comportamento della Loss durante il Training

Durante il training, l'obiettivo è minimizzare questa loss. Una loss perfetta sarebbe 0, che si verificherebbe se il modello assegnasse una probabilità di 1 alla classe corretta e 0 a tutte le altre classi. Tuttavia, questo è raramente raggiungibile in pratica.

Nel nostro caso, la loss di training è diminuita significativamente durante le prime epoche:

  • All'inizio: ~1.38 (valore alto che indica predizioni casuali)
  • Fine della prima epoca: ~0.37 (miglioramento significativo)
  • Fine della seconda epoca: ~0.17 (ulteriore affinamento)

Questa rapida diminuzione della loss indica che il modello stava apprendendo efficacemente dai dati. Allo stesso tempo, la loss di validazione si è stabilizzata intorno a ~0.24, indicando un buon equilibrio tra underfitting e overfitting.

Ottimizzazione basata sulla Loss

La loss viene utilizzata per aggiornare i parametri del modello attraverso la backpropagation:

# Backpropagation e ottimizzazione
optimizer.zero_grad() # Azzera i gradienti accumulati di default da PyThorc
loss.backward() # Calcola i gradienti rispetto alla loss

optimizer.step() # agggiorna i pesiCode language: PHP (php)

L'ottimizzatore Adam utilizza i gradienti calcolati dalla backpropagation per aggiornare i parametri del modello in modo da minimizzare la loss. Il learning rate di 1e-5 garantisce aggiornamenti sufficientemente piccoli per non saltare il minimo ottimale.

Vantaggi della Cross Entropy per la Classificazione Multiclasse

La Cross Entropy è particolarmente vantaggiosa per la classificazione multiclasse perché:

  1. Penalizza fortemente le predizioni errate con alta confidenza
  2. Fornisce un gradiente significativo anche quando la classificazione è corretta ma con bassa confidenza
  3. Funziona bene con l'output softmax che normalizza le probabilità tra le classi

Questi vantaggi hanno reso la Cross Entropy la scelta ideale per il nostro task di classificazione delle notizie, contribuendo alla rapida convergenza e all'alta accuratezza del modello finale.

Distribuzione del Modello

La distribuzione ha seguito un processo multi-fase per garantire affidabilità e scalabilità:

1. Sviluppo dello Script di Inferenza

Ho creato uno script personalizzato inference.py per gestire l'inferenza del modello in produzione. Questo script è cruciale poiché definisce esattamente come il modello risponderà alle richieste in tempo reale. Lo script svolge tre compiti fondamentali:

A. Caricamento del Modello (model_fn)

La funzione model_fn si occupa di caricare il modello addestrato dalla sua posizione su disco all'avvio dell'endpoint. Questo processo avviene una sola volta all'inizializzazione dell'endpoint:

def model_fn(model_dir):
    # Log informativo per il debugging
    logger.info(f"Loading model from: {model_dir}")
    
    # Istanziazione dell'architettura del modello
    model = DistilBERTClass()
    
    # Caricamento dei pesi addestrati
    model_state_dict = torch.load(os.path.join(model_dir, 'pytorch_distilbert_news.bin'), 
                                  map_location=torch.device('cpu'))
    model.load_state_dict(model_state_dict)
    
    # Caricamento del tokenizer - importante per preprocessare nuovi input
    tokenizer_path = os.path.join(model_dir, 'tokenizer')
    
    # Gestione robusta del caricamento del tokenizer con fallback
    try:
        if os.path.exists(tokenizer_path):
            tokenizer = DistilBertTokenizer.from_pretrained(tokenizer_path)
        else:
            # Fallback al download da Hugging Face<
            tokenizer = DistilBertTokenizer.from_pretrained('distilbert-base-uncased')
    except Exception as e:
        logger.error(f"Errore nel caricamento del tokenizer: {e}")
        tokenizer = DistilBertTokenizer.from_pretrained('distilbert-base-uncased')
    
    return modelCode language: Python (python)

B. Preprocessing degli Input (input_fn)

La funzione input_fn si occupa di trasformare i dati in arrivo dal client (tipicamente in formato JSON) in un formato che il modello può processare:

def input_fn(request_body, request_content_type): # Verifica che il formato dei dati in ingresso sia corretto</em>
    if request_content_type == 'application/json':
        # Parsing del JSON
        input_data = json.loads(request_body)
        # Estrazione del testo da classificare
        sentence = input_data['inputs']
        return sentence
    else:
        # Gestione degli errori per formati non supportati
        raise ValueError(f"Unsupported content type: {request_content_type}")Code language: PHP (php)

C. Predizione e Formattazione delle Risposte (predict_fn e output_fn)

Queste funzioni costituiscono il cuore dell'inferenza, eseguendo la predizione effettiva sul modello e formattando la risposta da restituire al client:

def predict_fn(input_data, model):
    # Selezione del dispositivo (GPU o CPU)
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model.to(device)
    
    # Tokenizzazione dell'input
    inputs = tokenizer(input_data, return_tensors="pt").to(device)
    ids = inputs['input_ids'].to(device)
    mask = inputs['attention_mask'].to(device)
    
    # Predizione con il modello
    model.eval()
    with torch.no_grad():
        outputs = model(ids, mask)
    
    # Conversione dei logits in probabilità con softmax
    probabilities = torch.softmax(outputs, dim=1).cpu().numpy()
    
    # Mappatura dell'indice della classe prevista al nome leggibile
    class_names = ["Business", "Science", "Entertainment", "Health"]
    predicted_class = probabilities.argmax(axis=1)[0]
    predicted_label = class_names[predicted_class]
    
    # Costruzione della risposta strutturata
    return {
        'predicted_label': predicted_label,
        'probabilities': probabilities.tolist()
    }

def output_fn(prediction, accept):
    # Formattazione della risposta nel formato richiesto dal client
    if accept == 'application/json':
        return json.dumps(prediction), accept
    else:
        # Gestione degli errori per formati non supportati<
        raise ValueError(f"Unsupported accept type: {accept}")Code language: PHP (php)

Questa architettura modulare per l'inferenza consente:

  • Flessibilità: Supporto per diversi formati di input/output
  • Robustezza: Gestione degli errori a ogni passaggio
  • Ottimizzazione: Riutilizzo del tokenizer e altre ottimizzazioni
  • Manutenibilità: Separazione chiara delle responsabilità

Il design dello script di inferenza è cruciale perché rappresenta l'interfaccia tra il modello addestrato e il mondo reale, determinando come il sistema risponderà alle richieste degli utenti in produzione.

2. Distribuzione dell'Endpoint SageMaker

Il modello è stato pacchettizzato e distribuito a un endpoint SageMaker:

from sagemaker.huggingface import HuggingFaceModel
from sagemaker import get_execution_role

role = get_execution_role()

model_s3_path = 's3://your-path/output/model.tar.gz'
huggingface_model = HuggingFaceModel(
    model_data=model_s3_path,
    role=role,
    transformers_version="4.6",
    pytorch_version="1.7",
    py_version="py36",
    entry_point="inference.py",
    name='model-for-deployment'
)

predictor = huggingface_model.deploy(
    initial_instance_count=1,
    instance_type='ml.m5.xlarge',
    Code language: JavaScript (javascript)

3. Integrazione della Funzione AWS Lambda

Per rendere il modello accessibile tramite API, ho creato una funzione Lambda

import json
import boto3

def lambda_handler(event, context):
    sagemaker_runtime = boto3.client('sagemaker-runtime')
    
    body = json.loads(event['body'])
    headline = body['query']['headline']
    endpoint_name = '<span style="background-color: initial; font-family: inherit; font-size: inherit; text-align: initial; color: initial;">your-endpoint-deploy-v1</span>'
    
    payload = json.dumps({"inputs": headline})
    response = sagemaker_runtime.invoke_endpoint(
        EndpointName=endpoint_name,
        ContentType='application/json',
        Body=payload
    )
    
    result = json.loads(response['Body'].read().decode())
    
    return {
        'statusCode': 200,
        'body': json.dumps(result)
    }Code language: JavaScript (javascript)

4. Configurazione di API Gateway

È stato configurato un API Gateway per esporre la funzione Lambda come endpoint API RESTful, con:

  • Metodo POST per le richieste di inferenza
  • Formato di richiesta/risposta JSON

Load Testing

Prima del rilascio completo in produzione, ho condotto test di carico per garantire che il sistema potesse gestire il traffico reale:

  1. Creato dati di test con vari titoli di notizie
  2. Utilizzato SageMaker Inference Recommender per ottimizzare la selezione dell'istanza
  3. Simulato richieste concorrenti per determinare i limiti di prestazione
  4. Monitorato latenza, throughput e tassi di errore

I risultati hanno mostrato che il sistema poteva gestire ~100 richieste al minuto su una singola istanza con una latenza media inferiore a 200ms, sufficiente per il traffico previsto.

Esempi di Risultati di Inferenza

Il test dell'API distribuita con titoli di esempio ha prodotto classificazioni accurate:

{
    "query":
    {
        "headline": "Nasa developed new spaceship"
    }
}
/ RISPOSTA /

["{\"predicted_label\": \"Science\", \"probabilities\": [[6.796713569201529e-05, 0.9996508359909058, 0.0002137843839591369, 6.745654536643997e-05]]}", "application/json"]Code language: JavaScript (javascript)

Sfide e Soluzioni

Rate Limiting del Tokenizer

L'endpoint di inferenza falliva frequentemente con errori "429 Too Many Requests" durante l'uso intensivo a causa di ripetuti download del tokenizer da Hugging Face. L'identificazione di questo problema è stata possibile solo grazie all'attento monitoraggio dei log di CloudWatch, evidenziando l'importanza cruciale degli strumenti di osservabilità in ambienti ML di produzione.

Soluzione: Refactoring del codice di inferenza per caricare il tokenizer una sola volta durante l'inizializzazione del modello, implementando una variabile globale e un sistema di caching locale. L'analisi dei log con CloudWatch ha permesso di individuare rapidamente l'origine del problema, risparmiando ore di debugging. Questa ottimizzazione ha ridotto drasticamente le richieste esterne e ha permesso all'endpoint di gestire volumi elevati di richieste senza interruzioni, dimostrando come un'efficace strategia di monitoraggio possa trasformarsi in miglioramenti tangibili delle prestazioni.

Apprendimento chiave: Gli strumenti di monitoraggio come CloudWatch non sono semplici accessori ma componenti fondamentali dell'infrastruttura ML, poiché consentono di identificare colli di bottiglia non evidenti durante lo sviluppo che emergono solo in condizioni di carico reali.

Conclusione Questo progetto ha dimostrato l'intero ciclo di vita ML dalla elaborazione dei dati alla distribuzione in produzione utilizzando AWS SageMaker. Il sistema finale raggiunge un'accuratezza superiore al 90% nella categorizzazione dei titoli di notizie e può essere facilmente integrato in piattaforme di aggregazione di notizie, sistemi di gestione dei contenuti o dashboard analitiche.

Apprendimenti Chiave:

  1. Il transfer learning con modelli pre-addestrati riduce drasticamente il tempo di addestramento e i requisiti di dati
  2. L'architettura del modello personalizzata può migliorare significativamente le prestazioni per compiti specifici
  3. AWS SageMaker semplifica la distribuzione ma richiede una configurazione attenta per l'ottimizzazione dei costi e la gestione delle dipendenze esterne
  4. Le architetture serverless forniscono scaling flessibile per i carichi di lavoro di inferenza ML
  5. La gestione efficiente delle risorse esterne (come i tokenizer) è cruciale per sistemi di produzione affidabili

Miglioramenti Futuri:

  • Implementare l'auto-scaling basato sui pattern di traffico
  • Esplorare tecniche di distillazione per ridurre le dimensioni del modello e i costi di inferenza
  • Aggiungere supporto per più lingue
  • Espandere a categorie di notizie più granulari
  • Implementare il packaging completo del modello inclusi i tokenizer per eliminare completamente le dipendenze esterne

Strumenti come questo classificatore di notizie non solo rendono l'organizzazione dei contenuti più efficiente, ma possono alimentare sistemi di raccomandazione, feed di notizie personalizzati e analisi delle tendenze per le aziende media.

Related Projects

Giugno 30, 2025
Veronica Chatbot - Sistema AI WordPress con Tool-based Retrieval ai-projects

Come ho trasformato un esperimento del corso di Ed Donner in un chatbot professionale integrato nel mio sito WordPress 📖 Il Punto di Partenza: Agenti AI Costruiti a Mano La settimana scorsa ho seguito il corso di Ed Donner sulla costruzione di agenti AI, dove abbiamo imparato a creare agenti "from scratch" Qui il link […]

Aprile 12, 2025
AI Travel Agent with AWS

Ogni tanto capita un progetto che ti cambia la prospettiva. Per me, questo è stato uno di quelli. Durante il corso Build AI Agents with AWS di Zero To Mastery, ho avuto la sensazione di aver sbloccato un nuovo livello: Mi ha fatto scoprire un mondo completamente nuovo: quello dell’orchestrazione multi-agente in cloud, dove ogni […]

veronicaschembri
Copyright © Veronica Schembri

Privacy Policy
Cookie Policy
💬