Home Fondamenti Token Modelli AI Deep Learning Tecniche RAG RAG Avanzato MCP Orchestrazione Prompt Engineering Usare l'AI ChipsBot News

Deep Learning: i miei progetti didattici

Usare un modello è facile, capire perché funziona è tutta un'altra storia. Nel 2023-2024, mentre l'ecosistema AI esplodeva intorno a noi, ho deciso di studiare le basi in modo sistematico: non solo usare le librerie, ma capire cosa c'è sotto. Ho scritto questi script Python partendo dai fondamenti — sklearn, poi PyTorch da zero, poi i Transformer — e ho tenuto tutto su GitHub. Non sono progetti destinati alla produzione: sono appunti di un percorso, con il codice che mi ha aiutato a capire davvero come funzionano le reti neurali, la backpropagation, l'attention e i LLM. Li condivido qui perché penso che questo tipo di approccio pratico sia utile anche ad altri.

— Francesco Gasparetto · github.com/fgasparetto

1. ml — Machine Learning Classico con scikit-learn

Il punto di partenza: tre algoritmi fondamentali su dataset reali. L'obiettivo non era padroneggiare l'API di sklearn, ma capire cosa succede sotto — le equazioni, i loop, le scelte di design.

Regressione lineare

Dataset: California Housing (20.640 campioni, 8 feature). Il modello impara i pesi β che minimizzano il Mean Squared Error. La soluzione esatta si trova con la Normal Equation:

β* = (XᵀX)⁻¹Xᵀy

Con sklearn si usa LinearRegression, che implementa questa equazione internamente (via SVD per stabilità numerica). Metriche monitorate: MSE, R² (coefficiente di determinazione).

from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.datasets import fetch_california_housing

X, y = fetch_california_housing(return_X_y=True)
model = LinearRegression()
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
print(f"MSE: {mean_squared_error(y_test, y_pred):.4f}")
print(f"R²:  {r2_score(y_test, y_pred):.4f}")

Regressione logistica

Dataset: Iris (3 classi). La sigmoide schiaccia l'output lineare in [0,1]; la loss è la Binary Cross-Entropy. Per multi-classe si usa lo schema One-vs-Rest: un classificatore per classe, vince chi ha probabilità più alta.

from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_iris

X, y = load_iris(return_X_y=True)
model = LogisticRegression(multi_class='ovr', max_iter=200)
model.fit(X_train, y_train)
print(f"Test accuracy: {model.score(X_test, y_test):.3f}")

K-Means

Dataset: Digits (1797 immagini 8×8 di cifre scritte a mano). K-Means è un algoritmo EM: l'E-step assegna ogni punto al centroide più vicino, l'M-step ricalcola i centroidi come media dei punti assegnati. La convergenza è garantita ma il risultato dipende dall'inizializzazione — K-Means++ sceglie i centroidi iniziali in modo intelligente, migliorando consistentemente i risultati.

from sklearn.cluster import KMeans
from sklearn.decomposition import PCA

kmeans = KMeans(n_clusters=10, init='k-means++', n_init=10, random_state=42)
kmeans.fit(X_digits)

# PCA a 2 componenti per visualizzare i cluster
pca = PCA(n_components=2)
X_2d = pca.fit_transform(X_digits)
sklearn vs PyTorch Con sklearn si scrivono 3 righe e il modello è addestrato. Con PyTorch si implementano il grafo computazionale, il loop di training, l'ottimizzatore. Sklearn è la scelta giusta per ML classico in produzione; PyTorch entra in gioco quando serve flessibilità architetturale o GPU.

2. parameters-tuning — Overfitting e Regolarizzazione

Un albero di decisione può memorizzare perfettamente il training set — ma poi non generalizza. Questo repository esplora il tradeoff bias-varianza in modo molto concreto.

Decision Tree su Breast Cancer Wisconsin

L'albero usa la Gini impurity come criterio di split. Ad ogni nodo, sceglie il feature e la soglia che massimizzano l'Information Gain:

IG = Gini(genitore) - [peso_sx * Gini(sx) + peso_dx * Gini(dx)]

Bias-Variance Tradeoff

L'errore di generalizzazione si scompone in tre termini:

Errore = Bias² + Varianza + Rumore irriducibile

Un albero profondo ha bias basso (si adatta bene al training) ma alta varianza (sensibile al rumore). Un albero poco profondo ha il problema opposto. L'esperimento concreto:

ConfigurazioneTrain accuracyTest accuracy
Depth illimitata1.0000.937
max_depth=40.9880.951

Il tree con depth illimitata memorizza il training set ma generalizza peggio. Con max_depth=4 si rinuncia a 1.2% di training accuracy e si guadagna 1.4% di test accuracy.

Cross-validation per trovare il parametro ottimale

from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score
import numpy as np

depths = range(1, 21)
cv_scores = []

for depth in depths:
    tree = DecisionTreeClassifier(max_depth=depth, random_state=42)
    scores = cross_val_score(tree, X, y, cv=5, scoring='accuracy')
    cv_scores.append(scores.mean())

best_depth = depths[np.argmax(cv_scores)]
print(f"Best depth: {best_depth}, CV score: {max(cv_scores):.3f}")
Nota pratica La cross-validation a 5 fold usa il 80% dei dati per training e 20% per validazione, rotando la finestra. Dà una stima molto più affidabile del test accuracy rispetto a un singolo train/test split.

3. pytorch-ml — PyTorch da Zero

Il salto concettuale principale: da sklearn (black box ottimizzata) a PyTorch (grafo computazionale esplicito). Questo repository reimplementa gli stessi algoritmi del primo, ma con autograd.

Autograd e grafo computazionale

PyTorch usa un approccio define-by-run: il grafo computazionale viene costruito dinamicamente durante l'esecuzione del forward pass. Ogni tensore tiene traccia delle operazioni che lo hanno prodotto, permettendo di calcolare i gradienti con la chain rule:

∂L/∂w = (∂L/∂ŷ) × (∂ŷ/∂w)

Il training loop canonico

import torch
import torch.nn as nn

model = MyModel()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.BCELoss()

for epoch in range(num_epochs):
    for X_batch, y_batch in dataloader:
        y_pred = model(X_batch)          # forward
        loss = criterion(y_pred, y_batch) # calcola loss
        optimizer.zero_grad()            # azzera gradienti
        loss.backward()                  # backward pass
        optimizer.step()                 # aggiorna pesi

MLP per classificazione su make_moons

I dati make_moons non sono linearmente separabili. Un singolo layer lineare fallisce; serve la non-linearità. ReLU (Rectified Linear Unit) — max(0, x) — è il motivo per cui le reti neurali profonde funzionano: ogni layer con ReLU crea una nuova partizione dello spazio delle feature.

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, 64),
            nn.ReLU(),
            nn.Linear(64, 64),
            nn.ReLU(),
            nn.Linear(64, 1),
            nn.Sigmoid()
        )
    def forward(self, x):
        return self.net(x).squeeze()

Adam optimizer

SGD aggiorna tutti i parametri con lo stesso learning rate. Adam mantiene un momentum (media mobile del gradiente) e un learning rate adattivo per parametro (inversamente proporzionale alla radice della media dei gradienti quadratici). In pratica converge molto più velocemente di SGD vanilla.

K-Means gradient-based

Un esperimento: i centroidi come nn.Parameter, la loss come somma delle distanze minime dai centroidi. Backprop aggiorna i centroidi. Non è il modo standard di fare K-Means, ma dimostra che l'autograd può ottimizzare qualsiasi funzione differenziabile.

4. deep-learning — MLP MNIST, ResNet, Object Detection

Il repository più denso. Tre aree: classificazione di immagini da zero, transfer learning con ResNet-50, e object detection.

MNIST: il "Hello World" del deep learning

60.000 immagini 28×28 di cifre scritte a mano, 10 classi. Il dataset è talmente standard che spesso viene usato per testare che l'infrastruttura funzioni, non tanto il modello. Un MLP semplice raggiunge ~98% di test accuracy.

class MNISTClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),
            nn.Linear(784, 256),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, 10)
        )
    def forward(self, x):
        return self.net(x)

# CrossEntropyLoss = LogSoftmax + NLLLoss in un solo passo
criterion = nn.CrossEntropyLoss()

Dropout disattiva casualmente una fraction dei neuroni durante il training, forzando la rete a non dipendere da singoli neuroni (regolarizzazione). Early stopping ferma il training quando la validation loss smette di migliorare per N epoche consecutive.

ResNet-50 e transfer learning

Le reti profonde soffrono del vanishing gradient: il gradiente si attenua strato dopo strato fino a diventare inutile. ResNet risolve il problema con le skip connections:

Output = F(x) + x Gradiente: ∂L/∂x = ∂L/∂y × (∂F/∂x + I) Il termine +I garantisce che il gradiente non si azzeri mai.

Con il transfer learning, si usa ResNet-50 pre-addestrata su ImageNet: i primi layer (che riconoscono edge, texture, forme) sono già ottimizzati e si "congelano". Si sostituisce solo l'ultimo layer di classificazione, che viene addestrato sul nostro dataset specifico.

import torchvision.models as models

resnet = models.resnet50(weights='IMAGENET1K_V2')

# Congela tutti i layer
for param in resnet.parameters():
    param.requires_grad = False

# Sostituisce solo il classificatore finale
num_classes = 5
resnet.fc = nn.Linear(resnet.fc.in_features, num_classes)

Object Detection

La detection con Sliding Window scorre finestre di dimensioni diverse sull'immagine, classificando ogni patch. È concettualmente semplice ma computazionalmente enorme. YOLO (You Only Look Once) divide l'immagine in una griglia e predice bounding box e classi in un singolo forward pass — decine di volte più veloce. Per il dataset di test ho generato un dataset sintetico di forme geometriche (cerchi, rettangoli, triangoli) usando PIL.

Nota pratica ResNet-50 preaddestrata su ImageNet generalizza sorprendentemente bene anche su domini visivamente lontani da ImageNet. Le feature dei layer intermedi (texture, contorni, pattern) sono quasi universali. Serve pochissimo fine-tuning.

5. image-ocr-form — Deployment ML in Django

Il gap tra "il modello funziona in un notebook" e "il modello funziona in produzione" è spesso sottovalutato. Questo repository esplora esattamente quel gap: una web app Django dove l'utente disegna una cifra su canvas HTML5 e il modello risponde in tempo reale.

Pipeline canvas → inference

Canvas HTML5 (Fabric.js) ──▶ AJAX POST ──▶ Django view ──▶ PyTorch inference ──▶ JSON risposta

Domain shift: il problema dell'inversione

MNIST è addestrato su cifre bianche su sfondo nero. Un canvas HTML5 di default ha sfondo bianco. Il modello addestrato su MNIST fallisce completamente. Soluzione: invertire i pixel prima dell'inferenza:

def preprocess_canvas_image(image_data_url):
    # Decodifica base64 dal canvas
    img_bytes = base64.b64decode(image_data_url.split(',')[1])
    img = Image.open(io.BytesIO(img_bytes)).convert('L')
    img = img.resize((28, 28))

    tensor = transforms.ToTensor()(img)
    tensor = 1.0 - tensor  # inversione: bianco→nero, nero→bianco
    tensor = transforms.Normalize((0.1307,), (0.3081,))(tensor)
    return tensor.unsqueeze(0)  # aggiunge batch dimension

Inference in Django

# Pattern singleton: carica una volta, riusa sempre
_model = None

def get_model():
    global _model
    if _model is None:
        _model = MNISTClassifier()
        _model.load_state_dict(torch.load('mnist_model.pth'))
        _model.eval()  # disattiva Dropout e BatchNorm per inference
    return _model

def predict_digit(request):
    tensor = preprocess_canvas_image(request.POST['image'])
    with torch.no_grad():   # risparmia memoria, non serve il grafo
        output = get_model()(tensor)
        pred = output.argmax(dim=1).item()
    return JsonResponse({'digit': pred})
model.eval() vs model.train() Dropout e BatchNorm si comportano diversamente in training e inference. model.eval() disattiva il Dropout (tutti i neuroni attivi) e usa le statistiche accumulate per BatchNorm. Dimenticarselo è un bug sottile che dà risultati peggiori senza errori espliciti.

6. nlp — NLP con Hugging Face Transformers

Da PyTorch da zero ai Transformer pre-addestrati. Hugging Face mette a disposizione centinaia di modelli con un'API uniforme. Questo repository copre 5 task NLP classici.

Self-Attention: il cuore dei Transformer

Ogni token produce tre vettori: Query, Key e Value. L'attention score misura quanto ogni token "deve prestare attenzione" agli altri:

Q = XW𝑞, K = XW𝑘, V = XW𝑣 Attention(Q, K, V) = softmax(QKᵀ/√dᵈ) · V

Il termine √dk normalizza i prodotti scalari per evitare che i gradienti del softmax diventino troppo piccoli. La Multi-Head Attention esegue questo meccanismo h volte in parallelo, ognuna su uno spazio proiettato diverso: ogni "testa" può specializzarsi su relazioni diverse (sintassi, coreference, dipendenze semantiche).

Encoder-only vs Decoder-only vs Encoder-Decoder

Encoder-only (BERT, RoBERTa)

Vede tutto il contesto (bidirezionale). Ideale per classificazione, NER, QA estrattiva. Pre-training: Masked LM + Next Sentence Prediction (BERT), solo MLM ottimizzato (RoBERTa).

Decoder-only (GPT)

Autoregressive: genera un token alla volta, guardando solo il passato. Naturale per generazione di testo. Pre-training: Causal LM su centinaia di miliardi di token.

Encoder-Decoder (T5, BART)

Encoder legge il testo in input, decoder genera l'output. Ideale per summarization, traduzione, riformulazione. BART è un denoising autoencoder: impara a ricostruire testo corrotto.

5 task con Hugging Face pipeline

from transformers import pipeline

# 1. Sentiment Analysis
sentiment = pipeline("sentiment-analysis")
print(sentiment("Questo modello funziona alla grande!"))

# 2. Question Answering estrattivo
qa = pipeline("question-answering", model="deepset/roberta-base-squad2")
print(qa(question="Chi ha scritto questo?", context=contesto))

# 3. Summarization
summarizer = pipeline("summarization", model="facebook/bart-large-cnn")
print(summarizer(testo_lungo, max_length=130, min_length=30))

# 4. Traduzione
translator = pipeline("translation_en_to_it", model="Helsinki-NLP/opus-mt-en-it")
print(translator("The attention mechanism is fundamental."))

# 5. Text Generation
generator = pipeline("text-generation", model="gpt2")
print(generator("Il deep learning è", max_new_tokens=50))
Tokenizzazione subword BERT usa WordPiece, GPT usa BPE. L'idea è la stessa: le parole comuni restano intere, quelle rare vengono spezzate in sotto-parole. "insolito" potrebbe diventare ["ins", "##olito"]. Questo bilancia vocabolario piccolo e copertura ampia della lingua.

7. nlp-form — QA in Web App Django con Hub Dinamico

Un passo avanti rispetto ai notebook: una web app Django dove l'utente sceglie il modello da un menu popolato dinamicamente dall'Hub di Hugging Face, inserisce contesto e domanda, e riceve la risposta.

Popolamento dinamico da HuggingFace Hub

from huggingface_hub import HfApi

def get_qa_models_from_hub():
    api = HfApi()
    models = api.list_models(
        task="question-answering",
        sort="downloads",
        direction=-1,
        limit=20
    )
    return [(m.modelId, m.modelId) for m in models]

# forms.py
class QAForm(forms.Form):
    model_name = forms.ChoiceField(choices=get_qa_models_from_hub())
    context = forms.CharField(widget=forms.Textarea())
    question = forms.CharField()

QA estrattiva: come funziona RoBERTa

Il modello non genera la risposta: la estrae. Riceve in input [CLS] domanda [SEP] contesto [SEP] e predice due indici: start e end dello span di risposta nel contesto. Le due teste di classificazione producono una distribuzione di probabilità su ogni token.

Pipeline caching in-process

# Cache dei modelli già caricati (dentro il processo Django)
_pipeline_cache = {}

def get_qa_pipeline(model_name):
    if model_name not in _pipeline_cache:
        _pipeline_cache[model_name] = pipeline(
            "question-answering",
            model=model_name
        )
    return _pipeline_cache[model_name]
ModelloLatency (prima richiesta)Latency (cached)
DistilBERT~3s (download)~50ms
RoBERTa-base~8s (download)~120ms
BERT-large~20s (download)~400ms

8. openai-projects — LLM via API OpenAI

GPT come strumento, non come oggetto di studio. L'attenzione si sposta su come usare i modelli in modo efficace: gestione del contesto, costi, pattern multi-turn.

Come funziona GPT (in breve)

GPT è un decoder Transformer addestrato con Causal Language Modeling: impara a predire ogni token dato il contesto precedente.

P(xᵢ | x₁, x₂, ..., xᵢ₋¹)

Pre-training su centinaia di miliardi di token, poi RLHF per allinearlo alle preferenze umane: prima un Supervised Fine-Tuning su dimostrazioni umane, poi un Reward Model addestrato su confronti, infine PPO (Proximal Policy Optimization) per massimizzare il reward.

Chat Completions API

from openai import OpenAI
client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "Sei un assistente tecnico."},
        {"role": "user", "content": "Spiega cos'è la backpropagation."}
    ],
    temperature=0.7,    # creatività: 0=deterministico, 1=casuale
    top_p=0.9          # nucleus sampling: considera solo il top 90%
)
print(response.choices[0].message.content)

Chatbot multi-turn con trim automatico

class ChatBot:
    def __init__(self, system_prompt, max_tokens=4000):
        self.messages = [{"role": "system", "content": system_prompt}]
        self.max_tokens = max_tokens

    def _trim_history(self):
        # Mantiene il messaggio system + gli ultimi N scambi
        while len(self.messages) > 10:
            self.messages.pop(1)  # rimuove il più vecchio (dopo system)

    def chat(self, user_message):
        self.messages.append({"role": "user", "content": user_message})
        self._trim_history()
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=self.messages
        )
        reply = response.choices[0].message.content
        self.messages.append({"role": "assistant", "content": reply})
        # Monitoraggio costi
        usage = response.usage
        print(f"Tokens: {usage.prompt_tokens}+{usage.completion_tokens}")
        return reply
Costi e token GPT-4o costa circa $2.50/1M token in input e $10/1M in output. Una conversazione da 50 scambi con risposte medie può costare $0.10-0.30. Il trim della history è fondamentale: ogni richiesta invia l'intera history al modello.

9. langchain-projects — Framework per Applicazioni LLM

LangChain astrae i pattern ripetitivi delle applicazioni LLM: chain, agenti, memory, RAG. Non è sempre la scelta giusta (a volte è troppo astratto), ma capire come funziona è utile anche per evitarlo consapevolmente.

Chains con LCEL

LangChain Expression Language (LCEL) usa l'operatore | per comporre componenti come una pipeline Unix:

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template(
    "Spiega {concetto} in modo semplice."
)
llm = ChatOpenAI(model="gpt-4o-mini")
parser = StrOutputParser()

# PromptTemplate | LLM | OutputParser
chain = prompt | llm | parser
result = chain.invoke({"concetto": "backpropagation"})

ReAct Agents con tool use

from langchain.agents import AgentExecutor, create_react_agent
from langchain_community.tools import DuckDuckGoSearchRun

tools = [DuckDuckGoSearchRun()]
agent = create_react_agent(llm, tools, prompt=react_prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

# Il loop: Thought → Action → Observation → Final Answer
result = executor.invoke({"input": "Quali sono i modelli AI più scaricati su HuggingFace?"})

RAG pipeline completo

from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter

# Indicizzazione
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(docs)
vectorstore = FAISS.from_documents(chunks, OpenAIEmbeddings())

# Retrieval con MMR (diversità + rilevanza)
retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 5, "fetch_k": 20}
)

# Generation
rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt_template
    | llm
    | StrOutputParser()
)

MMR (Maximum Marginal Relevance) bilancia rilevanza e diversità: invece di restituire i 5 chunk più simili alla query (che potrebbero essere quasi identici), alterna tra rilevanza e differenza dagli altri risultati già selezionati.

Memory

LangChain offre tre strategie per la memoria conversazionale: BufferMemory (mantiene tutto, diventa pesante), SummaryMemory (riassume periodicamente con un LLM, compatta), TokenBufferMemory (taglia la history quando supera N token, la scelta più pratica).

Quando evitare LangChain Per applicazioni semplici (una singola chain, nessun agente) LangChain aggiunge complessità senza valore. L'API OpenAI diretta è più leggibile e meno magic. LangChain brilla quando hai pipeline complesse, molti tool, o RAG avanzato con reranking e memory.

Risorse e Link

Tutti i repository sono pubblici su GitHub:

RepositoryTecnologieLink
ml scikit-learn, numpy, matplotlib github.com/fgasparetto/ml
parameters-tuning scikit-learn, Decision Tree, CV github.com/fgasparetto/parameters-tuning
pytorch-ml PyTorch, autograd, Adam github.com/fgasparetto/pytorch-ml
deep-learning PyTorch, MNIST, ResNet, YOLO, PIL github.com/fgasparetto/deep-learning
image-ocr-form Django, PyTorch inference, Fabric.js github.com/fgasparetto/image-ocr-form
nlp Hugging Face Transformers, BERT, BART github.com/fgasparetto/nlp
nlp-form Django, HuggingFace Hub, RoBERTa github.com/fgasparetto/nlp-form
openai-projects OpenAI API, GPT-4o, RLHF github.com/fgasparetto/openai-projects
langchain-projects LangChain, LCEL, FAISS, agents github.com/fgasparetto/langchain-projects

— Francesco Gasparetto, sviluppatore senior backend & AI enthusiast. Milano.
github.com/fgasparetto