"""
Corso Machine Learning Engineer - Modulo 00, Lezione 1
"Che cos'e davvero l'intelligenza artificiale (e che cosa non e)"

OBIETTIVO DI QUESTO FILE
------------------------
Tre esercizi che mettono alla prova le tre idee della lezione:

  1. la baseline non è lo zero: su classi sbilanciate "non fare nulla"
     ottiene già un'accuratezza altissima;
  2. "imparare" significa scegliere dentro una famiglia di funzioni F:
     allargare F (due soglie invece di una) può aiutare, ma non è gratis;
  3. il rischio empirico R^ non è il rischio vero R: il gap cresce con la
     complessità del modello.

COME SI USA
-----------
    python3 esercizi.py

Il file gira anche così com'è: stampa quali esercizi mancano ancora.
Completa i TODO uno alla volta e rilancia. Le soluzioni di riferimento sono
commentate in fondo al file: guardale solo dopo aver provato.

AMBIENTE DICHIARATO
-------------------
Eseguito con Python 3.14.4 e NumPy 2.3.5 (la lezione usa NumPy 2.3 e
scikit-learn 1.8; qui serve solo NumPy). Il seed è fissato, quindi i numeri
sono riproducibili: con versioni diverse di NumPy possono cambiare le ultime
cifre, mai il ragionamento.

    python3 -m venv .venv && source .venv/bin/activate
    pip install "numpy>=2.0"
"""

import numpy as np

SEED = 42


# ----------------------------------------------------------------------
# Dati simulati (gli stessi della lezione, dichiarati come simulati)
# ----------------------------------------------------------------------

def genera_messaggi(n, p_spam, rng):
    """Restituisce (X, y). X ha due colonne: n. esclamativi, n. link.
    y vale 1 se spam. Lo spam ha mediamente più esclamativi e più link."""
    y = rng.random(n) < p_spam
    esclam = np.where(y, rng.poisson(3.0, n), rng.poisson(0.5, n))
    link = np.where(y, rng.poisson(2.0, n), rng.poisson(0.4, n))
    return np.column_stack([esclam, link]), y.astype(int)


def accuratezza(pred, y):
    """Frazione di risposte corrette: la P della tripla (T, E, P)."""
    return float((pred == y).mean())


# ----------------------------------------------------------------------
# ESERCIZIO 1 - La baseline è il pavimento, non lo zero
#
# Il modello che "dichiara tutto non-spam" non guarda nemmeno i dati.
# Scrivi la funzione che ne calcola l'accuratezza, poi guarda che cosa
# succede quando lo spam passa dal 30% al 5% dei messaggi.
#
# Domanda a cui rispondere a parole, dopo aver visto i numeri:
#   con p_spam = 0.05, un modello "al 95%" ha imparato qualcosa?
# ----------------------------------------------------------------------

def accuratezza_baseline_maggioritaria(y):
    """Accuratezza del modello che risponde SEMPRE la classe più frequente.

    Parametri
    ---------
    y : np.ndarray di 0/1, le etichette vere.

    Ritorna
    -------
    float tra 0 e 1, oppure None se l'esercizio non è ancora svolto.

    Suggerimento: la classe più frequente è 1 se la media di y supera 0.5.
    """
    # TODO (esercizio 1): sostituisci `return None` con il calcolo.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 2 - Allargare la famiglia F
#
# Nella lezione la famiglia F era "esclamativi + link >= soglia": una sola
# manopola. Qui F diventa "esclamativi >= s1 AND link >= s2": due manopole.
# Cerca la coppia (s1, s2) che massimizza l'accuratezza sugli esempi di
# addestramento - è lo stesso argmin del rischio empirico, solo su una
# famiglia più grande.
#
# Domanda: la famiglia più grande vince anche sui dati MAI VISTI?
# ----------------------------------------------------------------------

def impara_due_soglie(X, y):
    """Cerca la coppia di soglie (s1, s2) della regola AND che massimizza
    l'accuratezza sugli esempi dati.

    Parametri
    ---------
    X : np.ndarray (n, 2) - colonna 0 = esclamativi, colonna 1 = link.
    y : np.ndarray (n,) di 0/1.

    Ritorna
    -------
    tupla (s1, s2) di interi, oppure None se l'esercizio non è ancora svolto.

    Suggerimento: due cicli annidati su range(0, X[:, c].max() + 2).
    La predizione è ((X[:, 0] >= s1) & (X[:, 1] >= s2)).astype(int).
    """
    # TODO (esercizio 2): sostituisci `return None` con la ricerca.
    return None


def predici_due_soglie(X, soglie):
    """Applica la regola AND imparata. Già scritta: non c'è nulla da fare."""
    s1, s2 = soglie
    return ((X[:, 0] >= s1) & (X[:, 1] >= s2)).astype(int)


# ----------------------------------------------------------------------
# ESERCIZIO 3 - Il gap di generalizzazione
#
# Adatta polinomi di grado crescente a 15 punti rumorosi e misura l'errore
# quadratico medio su training e su test. Il grado è la complessità di F.
#
# Domanda: a quale grado l'errore sul test smette di scendere? E perché
# l'errore sul training invece non risale mai?
# ----------------------------------------------------------------------

def errori_polinomio(grado, x_tr, y_tr, x_te, y_te):
    """Adatta un polinomio di grado `grado` ai dati di training e restituisce
    la coppia (mse_train, mse_test).

    Ritorna
    -------
    tupla (float, float), oppure None se l'esercizio non è ancora svolto.

    Suggerimento: np.polyfit(x, y, grado) restituisce i coefficienti,
    np.polyval(coef, x) valuta il polinomio. L'MSE è np.mean((pred - y) ** 2).
    """
    # TODO (esercizio 3): sostituisci `return None` con il calcolo.
    return None


# ----------------------------------------------------------------------
# Runner: stampa i risultati degli esercizi svolti e segnala quelli mancanti
# ----------------------------------------------------------------------

def _sezione(titolo):
    print()
    print(titolo)
    print("-" * len(titolo))


def _da_fare(numero):
    print(f"  [ ] Esercizio {numero} non ancora svolto: completa il TODO qui sopra.")


def esegui_esercizio_1(rng):
    _sezione("Esercizio 1 - la baseline sulle classi sbilanciate")
    for p_spam in (0.30, 0.05):
        _, y = genera_messaggi(5000, p_spam, rng)
        acc = accuratezza_baseline_maggioritaria(y)
        if acc is None:
            _da_fare(1)
            return False
        print(f"  p_spam = {p_spam:.2f} -> baseline maggioritaria: {acc:.3f}")
    print("  Un modello 'al 95%' con p_spam = 0.05 non ha imparato nulla:")
    print("  pareggia soltanto il pavimento.")
    return True


def esegui_esercizio_2(rng):
    _sezione("Esercizio 2 - una soglia contro due soglie")
    X_tr, y_tr = genera_messaggi(5000, 0.30, rng)
    X_te, y_te = genera_messaggi(5000, 0.30, rng)

    # riferimento: la soglia unica sulla somma, quella della lezione
    punteggio_tr = X_tr.sum(axis=1)
    migliore, soglia_somma = -1.0, 0
    for soglia in range(0, int(punteggio_tr.max()) + 2):
        acc = accuratezza((punteggio_tr >= soglia).astype(int), y_tr)
        if acc > migliore:
            migliore, soglia_somma = acc, soglia
    acc_somma = accuratezza((X_te.sum(axis=1) >= soglia_somma).astype(int), y_te)
    print(f"  soglia unica sulla somma  = {soglia_somma} -> test: {acc_somma:.3f}")

    soglie = impara_due_soglie(X_tr, y_tr)
    if soglie is None:
        _da_fare(2)
        return False
    acc_due = accuratezza(predici_due_soglie(X_te, soglie), y_te)
    print(f"  due soglie con AND        = {soglie} -> test: {acc_due:.3f}")
    verdetto = "vince" if acc_due > acc_somma else "NON vince"
    print(f"  La famiglia più grande {verdetto} sui dati mai visti.")
    return True


def esegui_esercizio_3(rng):
    _sezione("Esercizio 3 - il gap di generalizzazione")
    verita = lambda x: np.sin(2 * np.pi * x)
    x_tr = rng.uniform(0, 1, 15)
    y_tr = verita(x_tr) + rng.normal(0, 0.25, 15)
    x_te = rng.uniform(0, 1, 500)
    y_te = verita(x_te) + rng.normal(0, 0.25, 500)

    print("  grado | errore train | errore test")
    for grado in (1, 3, 5, 9):
        risultato = errori_polinomio(grado, x_tr, y_tr, x_te, y_te)
        if risultato is None:
            _da_fare(3)
            return False
        mse_tr, mse_te = risultato
        print(f"  {grado:5d} | {mse_tr:12.4f} | {mse_te:11.4f}")
    print("  L'errore sul training scende sempre: misura la memoria, non la")
    print("  qualità. Solo la colonna di destra informa.")
    return True


def main():
    print("Corso MLE - Modulo 00, Lezione 1 - esercizi di codice")
    fatti = 0
    for esegui in (esegui_esercizio_1, esegui_esercizio_2, esegui_esercizio_3):
        # un generatore nuovo per esercizio: ognuno è riproducibile da solo
        if esegui(np.random.default_rng(SEED)):
            fatti += 1
    print()
    print(f"Esercizi completati: {fatti} su 3.")
    if fatti < 3:
        print("Completa i TODO e rilancia: python3 esercizi.py")


if __name__ == "__main__":
    main()


# ======================================================================
# SOLUZIONI DI RIFERIMENTO
# Decommenta una funzione alla volta, incollala al posto del TODO
# corrispondente e rilancia. Guardarle prima di aver provato non insegna
# niente: la difficoltà di questi esercizi è tutta nel ragionamento.
# ======================================================================
#
# --- Esercizio 1 -------------------------------------------------------
# def accuratezza_baseline_maggioritaria(y):
#     classe_piu_frequente = 1 if y.mean() > 0.5 else 0
#     pred = np.full_like(y, classe_piu_frequente)
#     return accuratezza(pred, y)
#
# Atteso: 0.692 con p_spam = 0.30, 0.949 con p_spam = 0.05 (seed 42).
# Il secondo numero è il punto: un modello "al 95%" su quel problema non ha
# imparato nulla. Con classi sbilanciate l'accuratezza è una misura P
# sbagliata - servono precisione, richiamo, F1 (lezione 04 del modulo).
#
# --- Esercizio 2 -------------------------------------------------------
# def impara_due_soglie(X, y):
#     migliore, coppia_migliore = -1.0, (0, 0)
#     for s1 in range(0, int(X[:, 0].max()) + 2):
#         for s2 in range(0, int(X[:, 1].max()) + 2):
#             pred = ((X[:, 0] >= s1) & (X[:, 1] >= s2)).astype(int)
#             acc = accuratezza(pred, y)
#             if acc > migliore:
#                 migliore, coppia_migliore = acc, (s1, s2)
#     return coppia_migliore
#
# Atteso: (2, 1) con accuratezza 0.881 sul test, contro 0.914 della soglia
# unica sulla somma. Risultato controintuitivo e istruttivo: la famiglia F
# più grande contiene anche la regola migliore, ma qui perde. Due motivi,
# entrambi veri: (a) la regola AND non può esprimere "pochi esclamativi ma
# molti link", che la somma invece cattura; (b) con più manopole si adatta
# di più al rumore dei 5000 esempi di addestramento. Allargare F non è
# gratis - è esattamente il gap di generalizzazione dell'esercizio 3.
#
# --- Esercizio 3 -------------------------------------------------------
# def errori_polinomio(grado, x_tr, y_tr, x_te, y_te):
#     coef = np.polyfit(x_tr, y_tr, grado)
#     mse_tr = float(np.mean((np.polyval(coef, x_tr) - y_tr) ** 2))
#     mse_te = float(np.mean((np.polyval(coef, x_te) - y_te) ** 2))
#     return mse_tr, mse_te
#
# Atteso (seed 42): grado 1 -> train 0.1702 / test 0.2476; grado 3 -> 0.0226 /
# 0.0713; grado 5 -> 0.0215 / 0.0746; grado 9 -> 0.0196 / 0.1642. Il minimo
# sul test è al grado 3, poi l'errore sale mentre quello sul training
# continua a scendere: quella distanza è il gap. I numeri non coincidono con
# quelli dell'articolo perché lì il seed è 0 e qui è 42: il seed cambia i
# dati, non la forma della curva.
