"""
Corso Machine Learning Engineer - Modulo 03, Lezione 3
"Support Vector Machine: il margine massimo e il trucco del kernel"

OBIETTIVO DI QUESTO FILE
------------------------
Tre esercizi su un dominio nuovo rispetto all'articolo: distinguere l'olio
extravergine dall'olio lampante usando due soli indici chimici, l'acidità
libera (in % di acido oleico) e il numero di perossidi (in meq O2/kg). Due
feature, pochi punti, tutti i conti rifacibili a mano.

  1. l'iperpiano a margine massimo ricavato dai soli vettori di supporto:
     w, b e il margine, in forma chiusa;
  2. il trucco del kernel verificato numericamente: K(x,z) = (x.z)^2 vale
     esattamente phi(x).phi(z), e phi non viene mai calcolata;
  3. la trappola della scala: senza standardizzare, una feature decide da sola
     quali sono i vicini. Qui l'ordine si INVERTE dopo la standardizzazione.

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

Il file gira anche così com'è: stampa quali esercizi mancano ancora.

AMBIENTE DICHIARATO
-------------------
Python 3.12+ e NumPy 2.3. Nessun scikit-learn: qui si costruisce, non si chiama.

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

Tutti e tre gli esercizi sono DETERMINISTICI. Ogni numero atteso scritto in
fondo al file si ricava a mano: i dati sono scelti per avere conti puliti.
"""

import numpy as np

# ----------------------------------------------------------------------
# Due campioni, ed è tutto cio' che serve.
#
# In un problema a margine massimo separabile, l'iperpiano dipende SOLO dai
# vettori di supporto: tutti gli altri punti si possono buttare senza che la
# soluzione cambi di una virgola. Qui ce ne sono due, uno per classe.
#
#   extravergine (y = +1):  acidità 0.30 ,  perossidi 8.0    [unità riscalate]
#   lampante     (y = -1):  acidità 0.10 ,  perossidi 6.0
#
# Per rendere i conti leggibili lavoriamo in unità riscalate in cui i due
# punti stanno in (3, 3) e (1, 1). La geometria non cambia: un cambio di unità
# è una trasformazione lineare.
# ----------------------------------------------------------------------

SV_POSITIVO = np.array([3.0, 3.0])
SV_NEGATIVO = np.array([1.0, 1.0])


def iperpiano_da_vettori_di_supporto(x_pos, x_neg):
    """Iperpiano a margine massimo con un vettore di supporto per classe.

    Idea geometrica, prima della formula: il confine deve stare a metà strada
    tra i due punti e perpendicolare al segmento che li unisce. Quindi w è
    parallelo a (x_pos - x_neg) e il confine passa per il punto medio. La
    lunghezza di w è fissata dalla convenzione del margine canonico:

        w . x_pos + b = +1        w . x_neg + b = -1

    Sottraendo: w . (x_pos - x_neg) = 2. Con w = k (x_pos - x_neg) si ricava
    k = 2 / ||x_pos - x_neg||^2. Poi b si ottiene da una delle due equazioni,
    o più simmetricamente dal punto medio.

    Ritorna
    -------
    tupla (w, b) con w np.ndarray (2,) e b float,
    oppure None se l'esercizio non è ancora svolto.
    """
    # TODO (esercizio 1): sostituisci `return None` con il calcolo di w e b.
    return None


def valore_decisione(x, w, b):
    """f(x) = w . x + b. Già pronta."""
    return float(np.dot(w, x) + b)


def distanza_dal_confine(x, w, b):
    """Distanza geometrica di un punto dall'iperpiano. Già pronta.

    Non è |f(x)|: è |f(x)| / ||w||. Confondere le due cose è l'errore più
    comune quando si leggono gli output di una SVM.
    """
    return abs(valore_decisione(x, w, b)) / float(np.linalg.norm(w))


# ----------------------------------------------------------------------
# ESERCIZIO 2 - Il trucco del kernel, verificato
#
# Il kernel polinomiale omogeneo di grado 2 in due dimensioni è
#
#     K(x, z) = (x . z)^2
#
# La mappa esplicita corrispondente, in due dimensioni, è
#
#     phi(x) = ( x1^2 , sqrt(2) * x1 * x2 , x2^2 )
#
# Scrivi phi e verifica che phi(x) . phi(z) sia esattamente K(x, z).
#
# Domanda: in d dimensioni phi ha d(d+1)/2 componenti. Quante sono per
# d = 1000? E quanti prodotti costa invece K?
# ----------------------------------------------------------------------

def kernel_polinomiale_grado2(x, z):
    """K(x, z) = (x . z)^2. Già pronta: una moltiplicazione e un quadrato."""
    return float(np.dot(x, z) ** 2)


def mappa_esplicita_grado2(x):
    """phi(x) per il kernel polinomiale omogeneo di grado 2 in 2 dimensioni.

    Ritorna
    -------
    np.ndarray (3,) oppure None se l'esercizio non è ancora svolto.

    Il fattore sqrt(2) sul termine misto non è decorativo: senza di lui il
    prodotto scalare darebbe x1^2 z1^2 + x1 x2 z1 z2 + x2^2 z2^2, che NON è
    (x.z)^2. Provaci: sbagliare questo passaggio una volta serve a capire che
    la mappa non è "l'elenco dei monomi", è l'elenco dei monomi con i
    coefficienti del quadrato di un binomio.
    """
    # TODO (esercizio 2): sostituisci `return None` con phi(x).
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 3 - La trappola della scala
#
# Due campioni di olio, in unità REALI:
#
#     x = (acidità 0.25 , perossidi 12.0)
#     z = (acidità 0.70 , perossidi 13.0)
#
# L'acidità si misura in decimi di punto percentuale, i perossidi in decine.
# Il kernel RBF dipende solo da ||x - z||^2: calcola quanto contribuisce
# ciascuna feature, prima e dopo la standardizzazione con
#
#     media = (0.45, 14.0)       deviazione = (0.20, 5.0)
#
# Domanda: quale feature decide prima? E dopo?
# ----------------------------------------------------------------------

X_OLIO = np.array([0.25, 12.0])
Z_OLIO = np.array([0.70, 13.0])
MEDIA = np.array([0.45, 14.0])
DEVIAZIONE = np.array([0.20, 5.0])
GAMMA = 0.5


def contributi_alla_distanza(x, z):
    """Contributo di ciascuna feature a ||x - z||^2, in valore e in quota.

    Ritorna
    -------
    tupla (quadrati, quote) con due np.ndarray della stessa lunghezza di x,
    oppure None se l'esercizio non è ancora svolto.

    `quadrati[j]` è (x[j] - z[j])**2 e `quote[j]` è la sua frazione del
    totale. È la diagnosi più rapida che esista per capire se un modello
    basato su distanze stia guardando quello che credi.
    """
    # TODO (esercizio 3): sostituisci `return None` con i due vettori.
    return None


def standardizza(x, media, deviazione):
    """(x - media) / deviazione. Già pronta."""
    return (x - media) / deviazione


def kernel_rbf(x, z, gamma):
    """K(x, z) = exp(-gamma * ||x - z||^2). Già pronta."""
    return float(np.exp(-gamma * np.sum((x - z) ** 2)))


# ----------------------------------------------------------------------
# Runner
# ----------------------------------------------------------------------

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():
    _sezione("Esercizio 1 - l'iperpiano dai vettori di supporto")
    risultato = iperpiano_da_vettori_di_supporto(SV_POSITIVO, SV_NEGATIVO)
    if risultato is None:
        _da_fare(1)
        return False
    w, b = risultato
    norma = float(np.linalg.norm(w))
    print(f"  w = {np.round(w, 4)}   b = {b:.4f}   ||w|| = {norma:.4f}")
    print(f"  f(x_pos) = {valore_decisione(SV_POSITIVO, w, b):+.4f}"
          f"   (deve valere +1)")
    print(f"  f(x_neg) = {valore_decisione(SV_NEGATIVO, w, b):+.4f}"
          f"   (deve valere -1)")
    print(f"  distanza di ogni vettore di supporto dal confine:"
          f" {distanza_dal_confine(SV_POSITIVO, w, b):.4f}")
    print(f"  margine totale = 2 / ||w|| = {2.0 / norma:.4f}")
    lontano = np.array([9.0, 9.0])
    print(f"  un punto lontano come {lontano}: f = "
          f"{valore_decisione(lontano, w, b):+.4f}, distanza "
          f"{distanza_dal_confine(lontano, w, b):.4f}")
    print("  Aggiungerlo al dataset non cambierebbe w ne' b: non è un vettore")
    print("  di supporto. È la proprietà che rende le SVM poco sensibili ai")
    print("  punti lontani dal confine, e molto sensibili a quelli vicini.")
    return True


def esegui_esercizio_2():
    _sezione("Esercizio 2 - il trucco del kernel")
    coppie = [
        (np.array([1.0, 2.0]), np.array([3.0, -1.0])),
        (np.array([0.5, 0.5]), np.array([0.5, 0.5])),
        (np.array([2.0, 0.0]), np.array([0.0, 4.0])),
    ]
    for x, z in coppie:
        phi_x = mappa_esplicita_grado2(x)
        phi_z = mappa_esplicita_grado2(z)
        if phi_x is None or phi_z is None:
            _da_fare(2)
            return False
        diretto = kernel_polinomiale_grado2(x, z)
        esplicito = float(np.dot(phi_x, phi_z))
        print(f"  x = {x}  z = {z}")
        print(f"    K(x,z) = {diretto:10.4f}   phi(x).phi(z) = {esplicito:10.4f}"
              f"   differenza {abs(diretto - esplicito):.2e}")
    for d in (2, 10, 1000):
        print(f"  d = {d:>4}: componenti di phi = {d * (d + 1) // 2:>7}"
              f"   prodotti per K = {d}")
    print("  Il kernel costa quanto un prodotto scalare, qualunque sia il grado.")
    return True


def esegui_esercizio_3():
    _sezione("Esercizio 3 - la trappola della scala")
    for etichetta, x, z in (
        ("unità reali   ", X_OLIO, Z_OLIO),
        ("standardizzate ", standardizza(X_OLIO, MEDIA, DEVIAZIONE),
         standardizza(Z_OLIO, MEDIA, DEVIAZIONE)),
    ):
        risultato = contributi_alla_distanza(x, z)
        if risultato is None:
            _da_fare(3)
            return False
        quadrati, quote = risultato
        print(f"  {etichetta} x = {np.round(x, 3)}  z = {np.round(z, 3)}")
        print(f"      scarti^2 = {np.round(quadrati, 4)}"
              f"   totale {quadrati.sum():.4f}")
        print(f"      quote    = acidità {quote[0]:.1%}"
              f" , perossidi {quote[1]:.1%}")
        print(f"      K_rbf(gamma={GAMMA}) = {kernel_rbf(x, z, GAMMA):.4f}")
    print("  Stessa coppia di campioni, due verdetti opposti su chi decide.")
    print("  Per questo lo scaler sta DENTRO la pipeline, prima della SVM:")
    print("  senza, l'iperpiano a margine massimo massimizza il margine nelle")
    print("  unità di misura di chi ha scritto il CSV.")
    return True


def main():
    print("Corso MLE - Modulo 03, Lezione 3 - esercizi di codice")
    fatti = sum(bool(f()) for f in
                (esegui_esercizio_1, esegui_esercizio_2, esegui_esercizio_3))
    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
# ======================================================================
#
# --- Esercizio 1 -------------------------------------------------------
# def iperpiano_da_vettori_di_supporto(x_pos, x_neg):
#     differenza = x_pos - x_neg
#     w = 2.0 * differenza / float(np.dot(differenza, differenza))
#     b = -float(np.dot(w, (x_pos + x_neg) / 2.0))
#     return w, b
#
# Atteso, tutto a mano:
#   x_pos - x_neg = (2, 2),  ||.||^2 = 8,  w = 2*(2,2)/8 = (0.5, 0.5)
#   punto medio = (2, 2),  b = -(0.5*2 + 0.5*2) = -2.0
#   f(3,3) = 1.5 + 1.5 - 2 = +1.0        f(1,1) = 0.5 + 0.5 - 2 = -1.0
#   ||w|| = sqrt(0.5) = 0.7071
#   distanza di ogni vettore di supporto = 1 / ||w|| = 1.4142
#   margine totale = 2 / ||w|| = 2.8284 = 2*sqrt(2)
# Controllo geometrico: la distanza tra i due punti è sqrt(8) = 2.8284, e
# coincide con il margine. Deve essere così: il confine è perpendicolare al
# segmento e passa per il suo punto medio, quindi il corridoio vuoto è esat-
# tamente lungo quanto il segmento.
#   il punto (9,9) ha f = +7.0 e distanza 9.8995: è dentro la zona giusta e
#   lontanissimo dal confine, quindi il suo moltiplicatore di Lagrange è zero
#   e non partecipa alla soluzione.
#
# --- Esercizio 2 -------------------------------------------------------
# def mappa_esplicita_grado2(x):
#     return np.array([x[0] ** 2, np.sqrt(2.0) * x[0] * x[1], x[1] ** 2])
#
# Atteso: differenza nulla a meno dell'errore di arrotondamento (ordine 1e-15
# o 0, mai più di 1e-12). Verifica algebrica, da fare una volta nella vita:
#   phi(x).phi(z) = x1^2 z1^2 + 2 x1 x2 z1 z2 + x2^2 z2^2
#                 = (x1 z1 + x2 z2)^2 = (x . z)^2.
# È il quadrato di un binomio, da destra a sinistra.
#   Prima coppia: x.z = 1*3 + 2*(-1) = 1, quindi K = 1.
#   Seconda coppia: x.z = 0.5, quindi K = 0.25.
#   Terza coppia: x e z sono ortogonali, x.z = 0, quindi K = 0: nello spazio
#   trasformato restano ortogonali, e il kernel lo dice senza trasformare
#   niente.
#   d = 2 -> phi ha 3 componenti; d = 10 -> 55; d = 1000 -> 500500 componenti
#   contro 1000 moltiplicazioni per K. Il risparmio è il trucco: si lavora in
#   uno spazio che non si costruisce mai.
#
# --- Esercizio 3 -------------------------------------------------------
# def contributi_alla_distanza(x, z):
#     quadrati = (x - z) ** 2
#     totale = quadrati.sum()
#     quote = quadrati / totale if totale > 0 else np.zeros_like(quadrati)
#     return quadrati, quote
#
# Atteso in unità reali:
#   scarti = (0.25 - 0.70, 12.0 - 13.0) = (-0.45, -1.0)
#   quadrati = (0.2025, 1.0)   totale = 1.2025
#   quote = (16.8%, 83.2%)  ->  decidono i perossidi
#   K_rbf = exp(-0.5 * 1.2025) = exp(-0.60125) = 0.5483
#
# Atteso dopo la standardizzazione:
#   x' = ((0.25-0.45)/0.20, (12-14)/5) = (-1.00, -0.40)
#   z' = ((0.70-0.45)/0.20, (13-14)/5) = (+1.25, -0.20)
#   scarti = (-2.25, -0.20)   quadrati = (5.0625, 0.04)   totale = 5.1025
#   quote = (99.2%, 0.8%)  ->  decide l'acidità
#   K_rbf = exp(-0.5 * 5.1025) = exp(-2.55125) = 0.0780
#
# Il verdetto si è INVERTITO: la feature che prima pesava l'83% ora pesa
# meno dell'1%. Nessuno dei due risultati è "giusto" in se': la distanza
# euclidea somma quadrati di grandezze con unità diverse, un'operazione che
# in fisica non si potrebbe nemmeno scrivere. Standardizzare non è una
# comodità, è la scelta - dichiarata - di quale unità comune usare.
# Corollario: lo scaler va addestrato SOLO sul training (media e deviazione
# sono parametri appresi), altrimenti si ricade nel data leakage della
# lezione 1 di questo modulo.
