llm jupyter machine learning python gpu

Jupyter Notebook per LLM: Setup e Workflow Completo

Guida completa per usare Jupyter Notebook con Large Language Models. Setup ambiente, configurazione GPU, workflow pratici e best practices.

Ogni volta che devo sperimentare con un nuovo modello LLM, apro Jupyter. Non VS Code, non uno script Python, Jupyter. C'e qualcosa nell'esecuzione cella per cella che si sposa perfettamente con il workflow esplorativo tipico del lavoro con i language model.

Il problema e che configurare un ambiente Jupyter per LLM non e banale come installare un paio di pacchetti. Tra CUDA, quantizzazione, gestione della memoria GPU, e le mille dipendenze dei framework moderni, e facile perdersi. Questo articolo e il setup che avrei voluto trovare quando ho iniziato.

Perche Jupyter funziona bene con gli LLM

Il motivo principale e semplice: caricare un modello richiede tempo. Con Llama 3 8B parliamo di 20-30 secondi, con modelli piu grandi anche minuti. In uno script tradizionale, ogni volta che modifichi qualcosa devi ricaricare tutto. In Jupyter carichi il modello una volta e poi sperimenti quanto vuoi.

Questo cambia completamente il modo di lavorare. Puoi testare dieci prompt diversi in un minuto, modificare parametri al volo, confrontare output side by side. Per il prompt engineering e fondamentale.

C'e anche il vantaggio della documentazione integrata. Le celle markdown ti permettono di annotare cosa stai provando e perche. Quando torni su un notebook dopo un mese, capisci subito cosa stavi facendo. Con gli script puri, buona fortuna a ricordarti il contesto.

Preparare l'ambiente

Prima regola: isola sempre le dipendenze. I pacchetti per LLM hanno requisiti specifici e spesso conflittuali. Un virtual environment dedicato ti evita mal di testa.

# preferisco conda per progetti con CUDA
conda create -n llm python=3.11
conda activate llm

Puoi usare anche venv, ma conda gestisce meglio le dipendenze CUDA. Se sei su Mac con Apple Silicon, venv va benissimo.

Installa Jupyter Lab (non il notebook classico, Lab e nettamente superiore come esperienza):

pip install jupyterlab

Poi le dipendenze per LLM. Questo e il mio setup base:

pip install torch torchvision torchaudio
pip install transformers accelerate
pip install bitsandbytes  # per quantizzazione
pip install ollama  # se usi Ollama

Se hai bisogno di LangChain per RAG o chain complesse:

pip install langchain langchain-community

Verificare che la GPU funzioni

La prima cella di ogni mio notebook LLM e questa:

import torch

print(f"PyTorch: {torch.__version__}")
print(f"CUDA disponibile: {torch.cuda.is_available()}")

if torch.cuda.is_available():
    print(f"GPU: {torch.cuda.get_device_name(0)}")
    print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")

Se CUDA non risulta disponibile ma hai una GPU NVIDIA, il problema e quasi sempre una versione di PyTorch incompatibile con i tuoi driver. La soluzione:

pip uninstall torch
pip install torch --index-url https://download.pytorch.org/whl/cu121

Sostituisci cu121 con la versione CUDA che hai installato (controlla con nvidia-smi).

Per monitorare l'uso della GPU durante l'esecuzione, questa funzione e utile:

import subprocess

def vram_status():
    result = subprocess.run(
        ['nvidia-smi', '--query-gpu=memory.used,memory.total', '--format=csv,nounits,noheader'],
        capture_output=True, text=True
    )
    used, total = map(int, result.stdout.strip().split(', '))
    print(f"VRAM: {used} MB / {total} MB ({used/total*100:.1f}%)")

La chiamo prima e dopo operazioni pesanti per capire quanto sta consumando il modello.

Caricare modelli

Ci sono diversi modi per caricare un LLM in Jupyter. Il piu comune usa Hugging Face Transformers:

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model_name = "meta-llama/Meta-Llama-3-8B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.float16,
    device_map="auto"
)

Il problema e che questo carica il modello in FP16, che per Llama 3 8B significa circa 16 GB di VRAM. Se non hai abbastanza memoria, devi quantizzare:

from transformers import BitsAndBytesConfig

quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_quant_type="nf4"
)

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=quantization_config,
    device_map="auto"
)

Con la quantizzazione 4-bit, Llama 3 8B sta comodamente in 6 GB di VRAM. La qualita cala leggermente, ma per la maggior parte dei task non te ne accorgi.

Se usi Ollama, e tutto piu semplice:

import ollama

response = ollama.generate(
    model='llama3',
    prompt='Spiega cosa fa questo codice...'
)
print(response['response'])

Ollama gestisce tutto internamente - quantizzazione, VRAM, cache. Perdi un po' di controllo ma guadagni in semplicita.

Una funzione di generazione decente

Questa e la funzione che uso nella maggior parte dei miei notebook:

def genera(prompt, max_tokens=512, temperature=0.7, system_prompt="Sei un assistente utile."):
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": prompt}
    ]

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )

    inputs = tokenizer(text, return_tensors="pt").to(model.device)

    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=max_tokens,
            temperature=temperature,
            do_sample=True,
            top_p=0.9,
            pad_token_id=tokenizer.eos_token_id
        )

    # decodifica solo la parte generata
    response = tokenizer.decode(
        outputs[0][inputs['input_ids'].shape[1]:],
        skip_special_tokens=True
    )

    return response

Poi la uso cosi:

risposta = genera("Cos'e un container Docker?")
print(risposta)

La cosa comoda e che posso modificare i parametri al volo per sperimentare:

# piu creativo
genera("Scrivi una storia breve", temperature=0.9)

# piu deterministico
genera("Spiega TCP/IP", temperature=0.3)

Gestire la memoria

La VRAM e preziosa e i modelli LLM ne mangiano parecchia. Quando hai finito con un modello e vuoi caricarne un altro, devi liberare la memoria esplicitamente:

import gc

def libera_memoria():
    global model, tokenizer
    del model
    del tokenizer
    gc.collect()
    torch.cuda.empty_cache()

Se non lo fai, ti ritrovi con errori di memoria anche se teoricamente hai spazio. PyTorch tiene la memoria allocata finche non gliela chiedi indietro.

Per progetti dove devo confrontare piu modelli, ho scritto una classe che gestisce il caricamento/scaricamento:

class GestoreModelli:
    def __init__(self):
        self.model = None
        self.tokenizer = None
        self.nome_corrente = None

    def carica(self, nome_modello, quantizza=True):
        if self.nome_corrente == nome_modello:
            return  # gia caricato

        if self.model:
            del self.model
            del self.tokenizer
            gc.collect()
            torch.cuda.empty_cache()

        print(f"Carico {nome_modello}...")
        self.tokenizer = AutoTokenizer.from_pretrained(nome_modello)

        if quantizza:
            config = BitsAndBytesConfig(load_in_4bit=True)
            self.model = AutoModelForCausalLM.from_pretrained(
                nome_modello,
                quantization_config=config,
                device_map="auto"
            )
        else:
            self.model = AutoModelForCausalLM.from_pretrained(
                nome_modello,
                torch_dtype=torch.float16,
                device_map="auto"
            )

        self.nome_corrente = nome_modello
        print("Caricato.")

gestore = GestoreModelli()
gestore.carica("meta-llama/Meta-Llama-3-8B-Instruct")
# ... usa il modello ...
gestore.carica("mistralai/Mistral-7B-Instruct-v0.2")  # scarica il precedente automaticamente

Google Colab come alternativa

Se non hai una GPU locale, Colab e un'opzione valida. La versione gratuita ti da una T4 con 16 GB di VRAM, sufficiente per la maggior parte dei modelli 7-8B.

Setup tipico per Colab:

# verifica GPU
!nvidia-smi

# installa dipendenze (il ! esegue comandi shell)
!pip install -q transformers accelerate bitsandbytes

# monta Drive per salvare modelli (eviti di riscaricare ogni volta)
from google.colab import drive
drive.mount('/content/drive')

Il limite principale di Colab gratuito e la durata della sessione. Dopo 12 ore (o prima se inattivo) perdi tutto. Colab Pro estende a 24 ore e da accesso a GPU migliori.

Un trucco: salva i modelli quantizzati su Google Drive dopo il primo download. Al prossimo avvio li carichi da li invece di riscaricare da Hugging Face.

# salva
model.save_pretrained("/content/drive/MyDrive/modelli/llama3-8b-4bit")
tokenizer.save_pretrained("/content/drive/MyDrive/modelli/llama3-8b-4bit")

# carica dalla prossima volta
model = AutoModelForCausalLM.from_pretrained("/content/drive/MyDrive/modelli/llama3-8b-4bit")

Struttura che uso per i progetti

Dopo tanti esperimenti, ho trovato una struttura che funziona bene:

progetto/
├── 01-setup.ipynb          # caricamento modello, verifiche
├── 02-esperimenti.ipynb    # test vari, prompt engineering
├── 03-valutazione.ipynb    # metriche, confronti
└── risultati/              # output salvati

Il primo notebook lo eseguo una volta per caricare il modello. Gli altri li uso per sperimentare. Tenendo il modello caricato nel kernel, posso saltare tra notebook senza ricaricare ogni volta.

Per salvare i risultati degli esperimenti:

import json
from datetime import datetime

def salva_esperimento(nome, prompt, risposta, parametri):
    esperimento = {
        'timestamp': datetime.now().isoformat(),
        'nome': nome,
        'prompt': prompt,
        'risposta': risposta,
        'parametri': parametri
    }

    filename = f"risultati/{nome}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
    with open(filename, 'w') as f:
        json.dump(esperimento, f, indent=2, ensure_ascii=False)

Sembra overkill, ma quando dopo un mese devi capire quale combinazione di parametri dava i risultati migliori, ringrazierai te stesso.

Estensioni utili

Alcune estensioni di Jupyter Lab che mi hanno semplificato la vita:

pip install jupyterlab-nvdashboard  # monitoraggio GPU integrato
pip install jupyterlab-git          # git senza uscire da Jupyter

Il dashboard GPU e particolarmente utile. Vedi in tempo reale quanto stai usando la VRAM senza dover aprire un terminale separato con nvidia-smi.

Conclusioni

Jupyter non e l'unico modo per lavorare con LLM, ma per la fase di esplorazione e sperimentazione rimane il mio preferito. La possibilita di iterare velocemente, documentare al volo, e visualizzare i risultati nello stesso posto vale la complessita del setup iniziale.

Il consiglio che do sempre: investi tempo nel configurare bene l'ambiente una volta. Crea un template di notebook con le funzioni che usi sempre, salvalo, e usalo come punto di partenza per ogni nuovo progetto. Il tempo che risparmi sul lungo periodo e enorme.

E se sei su Colab perche non hai GPU, non vergognarti. Ho fatto progetti complessi interamente su Colab gratuito. L'importante e capire i limiti e lavorarci intorno.