"""
Corso Machine Learning Engineer - Modulo 03, Lezione 2
"Naive Bayes: il teorema di Bayes con un'ipotesi ingenua"

OBIETTIVO DI QUESTO FILE
------------------------
Tre esercizi su un dominio nuovo rispetto all'articolo: la diagnosi dei guasti
di una stampante 3D a partire dalle parole che l'operatore scrive nel registro
di officina ("sottoestrusione", "clic", "primo_strato", "distacco",
"temperatura"). Niente testi di newsgroup, niente spam: un vocabolario di
cinque parole, piccolo abbastanza da poter rifare ogni conto a mano.

  1. Bayes con il tasso di base: un sensore con il 90% di sensibilità su un
     guasto raro NON da' una diagnosi al 90%;
  2. Naive Bayes multinomiale scritto da zero, e l'effetto dello smoothing:
     con alpha = 0 una sola parola mai vista azzera tutto;
  3. l'ipotesi ingenua quando è falsa: duplicare una feature ELEVA AL QUADRATO
     il rapporto di verosimiglianza. Il modello diventa più sicuro senza che
     sia arrivata un'informazione nuova.

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 altro pacchetto.

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

Tutti e tre gli esercizi sono DETERMINISTICI: nessun numero casuale, nessun
seed. Ogni valore atteso scritto in fondo al file si ricava con carta, penna e
una tavola dei logaritmi.
"""

import numpy as np

# ----------------------------------------------------------------------
# ESERCIZIO 1 - Bayes e il tasso di base
#
# Su una flotta di stampanti, il 2% ha il cuscinetto dell'asse Y consumato.
# Un test di vibrazione:
#   - suona nel 90% dei casi in cui il cuscinetto è davvero consumato
#     (sensibilità);
#   - suona anche nell'8% dei casi in cui il cuscinetto è sano (falsi allarmi).
#
# Il test ha suonato. Qual è la probabilità che il cuscinetto sia consumato?
#
# Domanda: perché la risposta è MOLTO lontana dal 90%?
# ----------------------------------------------------------------------

def posteriore_binaria(prior, p_test_dato_guasto, p_test_dato_sano):
    """Probabilita' a posteriori di "guasto" dato che il test ha suonato.

    Parametri
    ---------
    prior : float - P(guasto) prima di qualunque misura.
    p_test_dato_guasto : float - P(test positivo | guasto), la sensibilità.
    p_test_dato_sano : float - P(test positivo | sano), il tasso di falsi allarmi.

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

    Suggerimento: il numeratore è il peso del ramo "guasto", il denominatore
    è la somma dei pesi di TUTTI i rami che portano a un test positivo.
    """
    # TODO (esercizio 1): sostituisci `return None` con la formula di Bayes.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 2 - Naive Bayes multinomiale, scritto da zero
#
# Venti rapporti di officina, dieci per guasto. Questi sono i conteggi delle
# cinque parole del vocabolario, sommati su tutti i rapporti di ciascuna classe.
#
#   parola          estrusore_intasato   piano_non_livellato
#   sottoestrusione         6                    1
#   clic                    4                    0
#   primo_strato            1                    7
#   distacco                0                    5
#   temperatura             3                    1
#   TOTALE                 14                   14
#
# Arriva un rapporto nuovo: "primo_strato distacco".
# Calcola il punteggio log di ciascuna classe e di' chi vince.
#
# Domanda: che cosa succede con alpha = 0? E perché con alpha piccolo il
# modello diventa PIÙ sicuro invece di meno?
# ----------------------------------------------------------------------

VOCABOLARIO = ["sottoestrusione", "clic", "primo_strato", "distacco", "temperatura"]
CLASSI = ["estrusore_intasato", "piano_non_livellato"]

# una riga per classe, una colonna per parola del vocabolario
CONTEGGI = np.array([
    [6, 4, 1, 0, 3],
    [1, 0, 7, 5, 1],
], dtype=float)

# dieci rapporti per classe: i prior sono 0.5 e 0.5
PRIOR_CLASSI = np.array([0.5, 0.5])


def vettorizza(rapporto):
    """Trasforma una lista di parole nel vettore dei conteggi. Già pronta."""
    v = np.zeros(len(VOCABOLARIO))
    for parola in rapporto:
        v[VOCABOLARIO.index(parola)] += 1.0
    return v


def punteggi_log_multinomiale(conteggi_classe, prior, documento, alpha):
    """Punteggio log di ogni classe per un documento, con smoothing di Laplace.

    Il punteggio della classe c è:

        log P(c) + somma_su_parole( n_parola * log P(parola | c) )

    dove la probabilità di una parola, con smoothing, è:

        P(parola | c) = (conteggio + alpha) / (totale_classe + alpha * |V|)

    Parametri
    ---------
    conteggi_classe : np.ndarray (n_classi, n_parole)
    prior : np.ndarray (n_classi,)
    documento : np.ndarray (n_parole,) - conteggi del documento nuovo.
    alpha : float - lo smoothing. Con 0 non c'è smoothing.

    Ritorna
    -------
    np.ndarray (n_classi,) di punteggi log, oppure None se non svolto.

    Due avvertenze che valgono anche nel codice di produzione:
      - si lavora in logaritmi perché il prodotto di molte probabilità
        piccole va in underflow: con 300 parole il prodotto è inferiore al
        più piccolo float rappresentabile;
      - con alpha = 0 e un conteggio nullo il logaritmo vale -inf. NumPy lo
        gestisce ma avvisa: usa `with np.errstate(divide="ignore")`. Il -inf
        NON è un bug, è la risposta: "quella classe è impossibile".

    TRAPPOLA, quella che costa mezz'ora: con alpha = 0 NON puoi scrivere
    `log_probabilita @ documento`. Per una parola assente dal documento il
    prodotto diventa `-inf * 0`, che in virgola mobile vale `nan`, e il `nan`
    contagia l'intera somma. Somma SOLO sulle parole effettivamente presenti:
    `np.where(documento > 0, log_probabilita * documento, 0.0).sum(axis=1)`.
    """
    # TODO (esercizio 2): sostituisci `return None` con il calcolo.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 3 - Quando l'ipotesi ingenua è falsa
#
# Una sola feature continua: lo scostamento della temperatura dell'ugello dal
# valore nominale, in gradi. Dai dati storici:
#
#   estrusore_intasato   media 8.0   deviazione 3.0
#   piano_non_livellato  media 2.0   deviazione 3.0
#
# Misuriamo x = 6.0. La misura è più compatibile con "intasato", ma non di
# molto: sta in mezzo.
#
# Ora immagina che il tuo dataset contenga la STESSA misura in due colonne
# (per esempio "temperatura_ugello" e "temp_ugello_celsius", esportate da due
# sistemi diversi). Naive Bayes le tratta come due prove indipendenti.
#
# Domanda: di quanto cresce la sicurezza del modello? E quanta informazione
# nuova è arrivata?
# ----------------------------------------------------------------------

MU_INTASATO, MU_LIVELLAMENTO, SIGMA = 8.0, 2.0, 3.0
X_MISURATO = 6.0


def log_verosimiglianza_gaussiana(x, mu, sigma):
    """Logaritmo della densità normale in x.

        log N(x; mu, sigma) = -log(sigma) - 0.5*log(2*pi) - 0.5*((x-mu)/sigma)**2

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

    Nota: i due primi termini non dipendono da x. Quando le due classi hanno la
    stessa sigma si cancellano nel rapporto, ed è il motivo per cui il conto a
    mano di questo esercizio è così breve.
    """
    # TODO (esercizio 3): sostituisci `return None` con la formula.
    return None


def probabilita_da_log_odds(log_odds):
    """Dalla quota logaritmica alla probabilità. Già pronta: è la sigmoide."""
    return 1.0 / (1.0 + np.exp(-log_odds))


# ----------------------------------------------------------------------
# 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 - Bayes e il tasso di base")
    p = posteriore_binaria(0.02, 0.90, 0.08)
    if p is None:
        _da_fare(1)
        return False
    print(f"  P(cuscinetto consumato | test positivo) = {p:.4f}")
    print(f"  prima del test era 0.0200: il test l'ha moltiplicata per "
          f"{p / 0.02:.2f}")
    for prior in (0.02, 0.10, 0.30, 0.50):
        print(f"  con prior {prior:.2f} -> posteriore "
              f"{posteriore_binaria(prior, 0.90, 0.08):.4f}")
    print("  Il test non cambia: cambia quanto era raro il guasto. La")
    print("  sensibilità da sola non è una diagnosi.")
    return True


def esegui_esercizio_2():
    _sezione("Esercizio 2 - Naive Bayes multinomiale da zero")
    documento = vettorizza(["primo_strato", "distacco"])
    for alpha in (1.0, 0.1, 0.0):
        with np.errstate(divide="ignore", invalid="ignore"):
            punteggi = punteggi_log_multinomiale(
                CONTEGGI, PRIOR_CLASSI, documento, alpha)
        if punteggi is None:
            _da_fare(2)
            return False
        vincente = CLASSI[int(np.argmax(punteggi))]
        differenza = float(punteggi[1] - punteggi[0])
        rapporto = float(np.exp(differenza)) if np.isfinite(differenza) else float("inf")
        print(f"  alpha = {alpha:>4}  punteggi {np.round(punteggi, 4)}"
              f"  -> {vincente}")
        print(f"               il rapporto di verosimiglianza a favore di"
              f" '{CLASSI[1]}' vale {rapporto:.1f}")
    print("  Meno smoothing = più sicurezza. Con alpha = 0 la sicurezza è")
    print("  infinita, e si basa su una parola che nei dati non c'era mai.")
    return True


def esegui_esercizio_3():
    _sezione("Esercizio 3 - l'ipotesi ingenua su feature duplicate")
    a = log_verosimiglianza_gaussiana(X_MISURATO, MU_INTASATO, SIGMA)
    b = log_verosimiglianza_gaussiana(X_MISURATO, MU_LIVELLAMENTO, SIGMA)
    if a is None or b is None:
        _da_fare(3)
        return False
    log_odds_una = a - b
    print(f"  log-verosimiglianze: intasato {a:.4f}   livellamento {b:.4f}")
    print("  copie della stessa colonna | log-odds | P(intasato) | rapporto")
    for copie in (1, 2, 3, 5):
        lo = copie * log_odds_una
        print(f"  {copie:>26} | {lo:8.4f} |"
              f" {probabilita_da_log_odds(lo):11.4f} | {np.exp(lo):8.3f}")
    print("  Nessuna informazione nuova è entrata: è sempre la stessa misura.")
    print("  La sicurezza cresce comunque, in modo esponenziale nel numero di")
    print("  copie. È il difetto più noto di Naive Bayes: ordina bene le")
    print("  classi, ma le sue probabilità non si possono prendere alla lettera.")
    return True


def main():
    print("Corso MLE - Modulo 03, Lezione 2 - 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 posteriore_binaria(prior, p_test_dato_guasto, p_test_dato_sano):
#     numeratore = prior * p_test_dato_guasto
#     denominatore = numeratore + (1.0 - prior) * p_test_dato_sano
#     return numeratore / denominatore
#
# Atteso, tutto a mano:
#   numeratore   = 0.02 * 0.90 = 0.0180
#   altro ramo   = 0.98 * 0.08 = 0.0784
#   denominatore = 0.0180 + 0.0784 = 0.0964
#   posteriore   = 0.0180 / 0.0964 = 0.1867
# Diciotto per cento, non novanta. Il motivo è nei numeri assoluti: su 1000
# stampanti, 20 hanno il guasto e 18 di quelle fanno suonare il test; delle
# 980 sane, 78 lo fanno suonare per errore. Gli allarmi sono 96, di cui 18
# veri: 18/96 = 0.1875 (la minima differenza con 0.1867 è il tondeggiamento
# del conteggio a interi). I falsi allarmi sono pochi in percentuale ma
# pescano da una popolazione enorme.
# Con prior 0.10 -> 0.5556; con 0.30 -> 0.8284; con 0.50 -> 0.9184. Il test
# è lo stesso: cambia solo quanto era plausibile il guasto PRIMA.
#
# --- Esercizio 2 -------------------------------------------------------
# def punteggi_log_multinomiale(conteggi_classe, prior, documento, alpha):
#     totali = conteggi_classe.sum(axis=1, keepdims=True)
#     n_parole = conteggi_classe.shape[1]
#     probabilita = (conteggi_classe + alpha) / (totali + alpha * n_parole)
#     with np.errstate(divide="ignore"):
#         log_probabilita = np.log(probabilita)
#     contributi = np.where(documento > 0, log_probabilita * documento, 0.0)
#     return np.log(prior) + contributi.sum(axis=1)
#
# Atteso con alpha = 1 (|V| = 5, totale per classe 14, denominatore 19):
#   P(primo_strato | intasato)    = (1 + 1) / 19 = 2/19
#   P(distacco     | intasato)    = (0 + 1) / 19 = 1/19
#   P(primo_strato | livellamento)= (7 + 1) / 19 = 8/19
#   P(distacco     | livellamento)= (5 + 1) / 19 = 6/19
# I prior sono uguali e si cancellano, quindi il rapporto è
#   (8/19 * 6/19) / (2/19 * 1/19) = 48 / 2 = 24.
# "piano_non_livellato" è 24 volte più verosimile. In logaritmo la
# differenza dei punteggi vale log 24 = 3.1781.
#
# Con alpha = 0.1 il denominatore diventa 14 + 0.5 = 14.5 e il rapporto è
#   (7.1 * 5.1) / (1.1 * 0.1) = 36.21 / 0.11 = 329.2.
# Con alpha = 0 il conteggio di "distacco" per "intasato" è zero: la
# probabilità è 0, il logaritmo -inf, il punteggio -inf e il rapporto
# infinito. Il modello dichiara IMPOSSIBILE la classe "estrusore_intasato"
# sulla base di una parola che semplicemente non era ancora comparsa in dieci
# rapporti. Questa è la ragione per cui lo smoothing non è un dettaglio di
# implementazione: senza, un'assenza di prove diventa una prova di assenza.
#
# La direzione dell'effetto è quella che sorprende: alpha PICCOLO rende il
# modello PIÙ sicuro. Lo smoothing non è rumore aggiunto, è prudenza; e
# alpha è un iperparametro, quindi si sceglie con la validazione, mai a occhio.
#
# --- Esercizio 3 -------------------------------------------------------
# def log_verosimiglianza_gaussiana(x, mu, sigma):
#     z = (x - mu) / sigma
#     return -np.log(sigma) - 0.5 * np.log(2 * np.pi) - 0.5 * z * z
#
# Atteso: le due classi hanno la stessa sigma, quindi nel rapporto restano
# solo gli esponenti.
#   z_intasato     = (6 - 8) / 3 = -2/3   ->  -0.5 * (4/9)  = -0.2222
#   z_livellamento = (6 - 2) / 3 =  4/3   ->  -0.5 * (16/9) = -0.8889
#   log-odds con UNA colonna = -0.2222 - (-0.8889) = +0.6667 = 2/3
#   rapporto di verosimiglianza = e^(2/3) = 1.9477
#   P(intasato) = 1.9477 / 2.9477 = 0.6608
# Con la colonna duplicata il log-odds raddoppia (4/3), il rapporto va a
# e^(4/3) = 3.7937 e la probabilità a 0.7914. Con tre copie: e^2 = 7.3891 e
# 0.8808. Con cinque: e^(10/3) = 28.03 e 0.9655.
# Il rapporto di verosimiglianza viene ELEVATO al numero di copie, perché
# Naive Bayes moltiplica le verosimiglianze credendole indipendenti. Nessuna
# informazione nuova, molta sicurezza in più.
# Due conseguenze pratiche: (a) prima di usare Naive Bayes si cercano le
# colonne quasi-duplicate e si tiene una sola delle due; (b) le probabilità
# in uscita si usano per ORDINARE, non come numeri da mostrare a un utente,
# a meno di ricalibrarle (isotonica o Platt) su dati di validazione.
