"""
Corso Machine Learning Engineer - Modulo 02, Lezione 3
"Probabilità: ragionare sull'incertezza"

DOMINIO DI QUESTO FILE
----------------------
Il varco di una **zona a traffico limitato simulata**: una telecamera
legge le targhe dei veicoli in ingresso e segnala quelle che ritiene non
autorizzate. Niente dati reali: la tabella e i tre parametri sono
scritti qui dentro, e sono scelti perché ogni probabilità venga una
**frazione esatta**.

Due situazioni distinte, da non confondere:

  A) una tabella di 200 veicoli osservati in un'ora, classificati per
     «autorizzato / non autorizzato» e «targa pulita / targa sporca»;
  B) il comportamento del lettore: prevalenza dei non autorizzati 1/50,
     sensibilità 24/25, falsi positivi 1/25.

I tre esercizi coprono le tre idee della lezione:

  1. **probabilità condizionata** e **indipendenza**, lette da una
     tabella di contingenza con `Fraction`, senza nessun arrotondamento;
  2. **il teorema di Bayes**: la probabilità che un veicolo segnalato
     sia davvero non autorizzato, e come cambia con la prevalenza;
  3. **la frequenza relativa**: la stessa quantità ottenuta simulando il
     varco, con la convergenza osservata al crescere delle prove - e la
     condizione vuota, che va gestita e non divisa per zero.

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

Il file gira anche così com'è: stampa quali esercizi mancano ancora.
I TODO si possono completare in qualunque ordine. L'esercizio 3 usa la
funzione dell'esercizio 2 come termine di confronto: se manca, lo dice e
salta quel confronto invece di rompersi.

AMBIENTE DICHIARATO
-------------------
Eseguito con Python 3.14.4. Solo libreria standard: `fractions`,
`random`. Le probabilità esatte sono `Fraction`, le stime per
simulazione sono `float`: i due tipi non si confrontano con `==`.
"""

from fractions import Fraction
import random

# ----------------------------------------------------------------------
# A) Tabella di contingenza simulata: 200 veicoli in un'ora
# ----------------------------------------------------------------------

# righe: autorizzato / non autorizzato ; colonne: targa pulita / sporca
TABELLA = {
    ("autorizzato", "pulita"): 120,
    ("autorizzato", "sporca"): 40,
    ("non autorizzato", "pulita"): 30,
    ("non autorizzato", "sporca"): 10,
}
RIGHE = ("autorizzato", "non autorizzato")
COLONNE = ("pulita", "sporca")
TOTALE = sum(TABELLA.values())          # 200

# ----------------------------------------------------------------------
# B) Parametri del lettore di targhe (dichiarati come simulati)
# ----------------------------------------------------------------------

PREVALENZA = Fraction(1, 50)            # veicoli non autorizzati in ingresso
SENSIBILITA = Fraction(24, 25)          # segnala un non autorizzato
FALSI_POSITIVI = Fraction(1, 25)        # segnala un autorizzato

# Prevalenze da confrontare nell'esercizio 2: la notte passano meno
# veicoli e la quota di non autorizzati sale. Numeri inventati.
PREVALENZE = (Fraction(1, 1000), Fraction(1, 100), PREVALENZA,
              Fraction(1, 10), Fraction(1, 5), Fraction(1, 2))

NUMEROSITA = (1_000, 10_000, 100_000, 1_000_000)
SEME = 20261016


# ----------------------------------------------------------------------
# ESERCIZIO 1 - Condizionare vuol dire restringere la stanza
#
# Dalla tabella di contingenza calcola, come `Fraction` esatte:
#
#   "p_non_autorizzato"          -> P(N)
#   "p_sporca"                   -> P(S)
#   "p_non_autorizzato_e_sporca" -> P(N ∩ S)
#   "p_non_autorizzato_se_sporca"-> P(N | S)
#   "p_sporca_se_non_autorizzato"-> P(S | N)
#   "indipendenti"               -> True se P(N ∩ S) == P(N)·P(S)
#
# Condizionare su S significa buttare via le righe che non sono S e
# ricalcolare le proporzioni dentro quello che resta: il denominatore
# non è più 200, è il totale della colonna «sporca».
#
# Domanda: P(N | S) e P(S | N) sono due numeri diversi. Che cosa
# risponde ciascuno dei due?
# ----------------------------------------------------------------------

def probabilita_dalla_tabella(tabella):
    """Probabilità marginali, congiunta e condizionate, esatte.

    Parametri
    ---------
    tabella : dict
        Chiavi (riga, colonna) -> conteggio intero.

    Ritorna
    -------
    dict con le sei chiavi elencate qui sopra, oppure None se
    l'esercizio non è ancora svolto.

    Suggerimento: usa `Fraction(numeratore, denominatore)` e non la
    divisione in virgola mobile. P(N | S) = P(N ∩ S) / P(S), ma si
    ottiene anche come conteggio di (N e S) diviso conteggio di S: i due
    modi devono dare la stessa frazione.
    """
    # TODO (esercizio 1): sostituisci `return None` con il dizionario.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 2 - Il teorema di Bayes
#
# Il lettore segnala un veicolo. Qual è la probabilità che quel veicolo
# sia davvero non autorizzato? Non è la sensibilità: quella risponde
# alla domanda opposta.
#
#   P(N | segnalato) = P(segnalato | N)·P(N) / P(segnalato)
#   P(segnalato) = P(segnalato | N)·P(N) + P(segnalato | A)·P(A)
#
# La seconda riga è la legge della probabilità totale: i segnalati sono
# i veri positivi più i falsi positivi, e i due gruppi sono disgiunti.
#
# Scrivi la funzione con `Fraction`: il risultato deve essere una
# frazione esatta, non un decimale arrotondato.
#
# Domanda: con prevalenza 1/50, sensibilità 24/25 e falsi positivi 1/25,
# il risultato è minore di 1/3. Dove finisce il resto?
# ----------------------------------------------------------------------

def posteriore(prevalenza, sensibilita, falsi_positivi):
    """P(non autorizzato | segnalato), esatta.

    Parametri
    ---------
    prevalenza : Fraction
        P(N), quota di veicoli non autorizzati in ingresso.
    sensibilita : Fraction
        P(segnalato | N).
    falsi_positivi : Fraction
        P(segnalato | autorizzato).

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

    Suggerimento: calcola prima il denominatore P(segnalato) con la
    legge della probabilità totale, poi dividi. Con `Fraction` la
    divisione resta esatta.
    """
    # TODO (esercizio 2): sostituisci `return None` con il calcolo.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 3 - La stessa quantità per simulazione
#
# Simula `n` veicoli: ciascuno è non autorizzato con probabilità
# `prevalenza`, e viene segnalato con probabilità `sensibilita` se non
# autorizzato, `falsi_positivi` se autorizzato. Poi conta: fra i
# segnalati, quanti erano davvero non autorizzati?
#
# Attenzione a due cose:
#   - la stima è un rapporto fra due conteggi CASUALI, quindi cambia a
#     ogni esecuzione se non fissi il seme;
#   - se nessun veicolo viene segnalato, il rapporto è 0/0. Non si
#     divide: si restituisce None per quel caso, che è diverso da zero.
#
# Domanda: perché la stima non si confronta con la frazione esatta
# usando `==`?
# ----------------------------------------------------------------------

def stima_per_simulazione(n, prevalenza, sensibilita, falsi_positivi, generatore):
    """Stima di P(non autorizzato | segnalato) per frequenza relativa.

    Parametri
    ---------
    n : int
        Numero di veicoli simulati.
    prevalenza, sensibilita, falsi_positivi : Fraction
        Gli stessi parametri dell'esercizio 2.
    generatore : random.Random
        Generatore con seme fissato, passato da fuori.

    Ritorna
    -------
    tupla (svolto, stima, segnalati, veri_positivi):
      - (False, None, 0, 0) finché l'esercizio non è svolto;
      - (True, None, 0, 0) se nessun veicolo è stato segnalato;
      - (True, float, int, int) altrimenti.

    Suggerimento: `generatore.random() < float(prevalenza)` decide se il
    veicolo è non autorizzato. Conta `segnalati` e `veri_positivi` in due
    variabili, e dividi solo se `segnalati > 0`.
    """
    # TODO (esercizio 3): sostituisci la riga qui sotto.
    return (False, None, 0, 0)


# ----------------------------------------------------------------------
# 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 _f(frazione):
    """Frazione esatta più il suo valore decimale, per leggerla."""
    return f"{frazione} = {float(frazione):.6f}"


def esegui_esercizio_1():
    _sezione("Esercizio 1 - condizionare vuol dire restringere la stanza")
    print(f"  veicoli osservati nell'ora simulata: {TOTALE}")
    print("                    | pulita | sporca | totale")
    for riga in RIGHE:
        pulita = TABELLA[(riga, "pulita")]
        sporca = TABELLA[(riga, "sporca")]
        print(f"  {riga:17s} | {pulita:6d} | {sporca:6d} | {pulita + sporca:6d}")
    colonne = [sum(TABELLA[(r, c)] for r in RIGHE) for c in COLONNE]
    print(f"  {'totale':17s} | {colonne[0]:6d} | {colonne[1]:6d} | {TOTALE:6d}")

    risultati = probabilita_dalla_tabella(TABELLA)
    if risultati is None:
        _da_fare(1)
        return False

    print("  quantità                      | valore esatto")
    for chiave in ("p_non_autorizzato", "p_sporca", "p_non_autorizzato_e_sporca",
                   "p_non_autorizzato_se_sporca", "p_sporca_se_non_autorizzato"):
        print(f"  {chiave:29s} | {_f(risultati[chiave])}")
    prodotto = risultati["p_non_autorizzato"] * risultati["p_sporca"]
    print(f"  P(N)·P(S)                     | {_f(prodotto)}")
    print(f"  indipendenti (P(N∩S) == P(N)·P(S)): {risultati['indipendenti']}")
    print("  P(N | S) risponde «fra le targhe sporche, quante non sono")
    print("  autorizzate?»; P(S | N) risponde «fra i non autorizzati, quante")
    print("  targhe sono sporche?». Denominatori diversi, domande diverse.")
    return True


def esegui_esercizio_2():
    _sezione("Esercizio 2 - il teorema di Bayes")
    valore = posteriore(PREVALENZA, SENSIBILITA, FALSI_POSITIVI)
    if valore is None:
        _da_fare(2)
        return False

    print(f"  sensibilità P(segnalato | N) = {_f(SENSIBILITA)}")
    print(f"  falsi positivi P(segnalato | A) = {_f(FALSI_POSITIVI)}")
    print(f"  prevalenza P(N) = {_f(PREVALENZA)}")
    print(f"  P(N | segnalato) = {_f(valore)}")
    print("  Non è la sensibilità: è la risposta alla domanda opposta.")

    print("  prevalenza | P(N | segnalato) esatta | in percentuale")
    for p in PREVALENZE:
        q = posteriore(p, SENSIBILITA, FALSI_POSITIVI)
        print(f"  {str(p):10s} | {str(q):23s} | {float(q) * 100:11.2f}%")

    # Conteggi attesi su un lotto scelto perché i numeri siano interi.
    lotto = 1250
    non_aut = int(PREVALENZA * lotto)
    veri_positivi = int(non_aut * SENSIBILITA)
    falsi = int((lotto - non_aut) * FALSI_POSITIVI)
    print(f"  su {lotto} veicoli: {non_aut} non autorizzati,"
          f" {veri_positivi} veri positivi, {falsi} falsi positivi")
    print(f"  segnalati in tutto: {veri_positivi + falsi}"
          f" | quota vera: {Fraction(veri_positivi, veri_positivi + falsi)}")
    print(f"  i {falsi} falsi positivi vengono dai {lotto - non_aut} autorizzati:")
    print("  una quota piccola di un gruppo grande batte una quota grande di")
    print("  un gruppo piccolo. È tutto il contenuto del teorema di Bayes.")
    return True


def esegui_esercizio_3():
    _sezione("Esercizio 3 - la stessa quantità per simulazione")
    esatto = posteriore(PREVALENZA, SENSIBILITA, FALSI_POSITIVI)
    if esatto is None:
        print("  [i] confronto con il valore esatto saltato:"
              " serve l'esercizio 2 (posteriore).")
    prova = stima_per_simulazione(10, PREVALENZA, SENSIBILITA,
                                  FALSI_POSITIVI, random.Random(SEME))
    if not prova[0]:
        _da_fare(3)
        return False

    print(f"  seme fissato: {SEME} (senza seme questa tabella cambia ogni volta)")
    print("        n | segnalati | veri positivi | stima    | scarto dall'esatto")
    for n in NUMEROSITA:
        _, stima, segnalati, veri = stima_per_simulazione(
            n, PREVALENZA, SENSIBILITA, FALSI_POSITIVI, random.Random(SEME))
        if stima is None:
            print(f"  {n:7d} | {segnalati:9d} | {veri:13d} |"
                  f" {'indefinita':8s} | nessun segnalato: 0/0, non si divide")
            continue
        scarto = "n.d." if esatto is None else f"{stima - float(esatto):+.6f}"
        print(f"  {n:7d} | {segnalati:9d} | {veri:13d} | {stima:.6f} | {scarto}")

    _, stima_piccola, segnalati_piccoli, _ = stima_per_simulazione(
        5, PREVALENZA, SENSIBILITA, FALSI_POSITIVI, random.Random(SEME))
    print(f"  con n = 5: segnalati {segnalati_piccoli},"
          f" stima {stima_piccola} <- la condizione può essere vuota")
    if esatto is not None:
        print(f"  valore esatto: {_f(esatto)}")
        print("  La stima non si confronta con `==`: è un rapporto fra conteggi")
        print("  casuali e non sarà mai identico a una frazione. Si confrontano")
        print("  l'ordine di grandezza dello scarto e la sua riduzione con n.")
    return True


def main():
    print("Corso MLE - Modulo 02, Lezione 3 - esercizi di codice")
    print("Dominio: il varco di una zona a traffico limitato SIMULATA.")
    fatti = 0
    for esegui in (esegui_esercizio_1, esegui_esercizio_2, esegui_esercizio_3):
        if esegui():
            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 probabilita_dalla_tabella(tabella):
#     totale = sum(tabella.values())
#     non_aut = sum(v for (r, _), v in tabella.items() if r == "non autorizzato")
#     sporca = sum(v for (_, c), v in tabella.items() if c == "sporca")
#     congiunta = tabella[("non autorizzato", "sporca")]
#     p_n = Fraction(non_aut, totale)
#     p_s = Fraction(sporca, totale)
#     p_ns = Fraction(congiunta, totale)
#     return {
#         "p_non_autorizzato": p_n,
#         "p_sporca": p_s,
#         "p_non_autorizzato_e_sporca": p_ns,
#         "p_non_autorizzato_se_sporca": Fraction(congiunta, sporca),
#         "p_sporca_se_non_autorizzato": Fraction(congiunta, non_aut),
#         "indipendenti": p_ns == p_n * p_s,
#     }
#
# Atteso: P(N) = 40/200 = 1/5, P(S) = 50/200 = 1/4,
# P(N ∩ S) = 10/200 = 1/20, P(N | S) = 10/50 = 1/5,
# P(S | N) = 10/40 = 1/4. E qui c'è il punto: P(N | S) = 1/5 è
# ESATTAMENTE P(N), quindi sapere che la targa è sporca non cambia la
# probabilità che il veicolo sia non autorizzato: i due eventi sono
# indipendenti, e infatti 1/20 = (1/5)·(1/4).
# Attenzione a cosa NON significa: l'indipendenza riguarda queste due
# caratteristiche dei veicoli, non la qualità della lettura. Una targa
# sporca può benissimo far sbagliare il lettore più spesso; quella è
# un'altra probabilità, condizionata a un altro evento.
# Che cosa risponde ciascuna condizionata: P(N | S) guarda dentro la
# colonna «sporca» (50 veicoli) e chiede quanti non sono autorizzati;
# P(S | N) guarda dentro la riga «non autorizzato» (40 veicoli) e chiede
# quante targhe sono sporche. Scambiarle è l'errore che la lezione chiama
# inversione della condizionata, ed è lo stesso di confondere
# sensibilità e precisione.
#
# --- Esercizio 2 -------------------------------------------------------
# def posteriore(prevalenza, sensibilita, falsi_positivi):
#     veri = sensibilita * prevalenza
#     falsi = falsi_positivi * (1 - prevalenza)
#     return veri / (veri + falsi)
#
# Atteso con i parametri del file: veri positivi = (24/25)·(1/50) =
# 24/1250, falsi positivi = (1/25)·(49/50) = 49/1250, denominatore
# 73/1250, quindi P(N | segnalato) = 24/73 ≈ 0,328767. Un lettore che
# riconosce il 96% dei non autorizzati produce, su 1250 veicoli, 24 veri
# positivi e 49 falsi: i segnalati sono 73 e meno di un terzo sono
# davvero irregolari.
# Dove finisce il resto: nei 49 falsi positivi. Il 4% di un gruppo da
# 1225 veicoli autorizzati vale più del 96% di un gruppo da 25. La
# prevalenza non è un dettaglio del problema, è il fattore dominante:
# nella tabella si vede che a prevalenza 1/1000 la posteriore scende a
# 24/1023 (circa 2,3%) e a prevalenza 1/2 sale a 24/25 (96%), con gli
# stessi parametri del lettore.
#
# --- Esercizio 3 -------------------------------------------------------
# def stima_per_simulazione(n, prevalenza, sensibilita, falsi_positivi, generatore):
#     p_n, p_sens, p_fp = float(prevalenza), float(sensibilita), float(falsi_positivi)
#     segnalati = veri_positivi = 0
#     for _ in range(n):
#         non_autorizzato = generatore.random() < p_n
#         soglia = p_sens if non_autorizzato else p_fp
#         if generatore.random() < soglia:
#             segnalati += 1
#             if non_autorizzato:
#                 veri_positivi += 1
#     if segnalati == 0:
#         return (True, None, 0, 0)
#     return (True, veri_positivi / segnalati, segnalati, veri_positivi)
#
# Atteso con il seme 20261016: la stima oscilla intorno a 0,3288 e lo
# scarto si riduce al crescere di n (vedi la tabella stampata, dove con
# un milione di veicoli lo scarto è dell'ordine di 1e-3). Con n = 5 non
# viene segnalato nessun veicolo: la stima è `None`, cioè INDEFINITA, e
# non zero. Dividere 0/0 sarebbe un `ZeroDivisionError`, ma il guaio
# peggiore sarebbe restituire 0.0: un valore plausibile al posto di
# «non lo so».
# Perché non si confronta con `==`: la stima è un rapporto fra due
# conteggi casuali, con una sua variabilità; la frazione esatta è un
# numero. Non coincideranno mai, e pretenderlo sarebbe come pretendere
# che 10 lanci di dado diano esattamente 1/6 per faccia. Si confronta lo
# SCARTO, e si verifica che si riduca come 1/radice(n): è la legge dei
# grandi numeri, che la lezione 04 rende quantitativa.
