"""
Corso Machine Learning Engineer - Modulo 00, Lezione 4
"Implementare l'IA in azienda: dal problema alla decisione, con i costi in mano"

OBIETTIVO DI QUESTO FILE
------------------------
Due esercizi di ragionamento quantitativo sulla decisione, non sul modello.
Sono gli stessi due esercizi carta-e-penna dell'articolo: qui il programma
ricalcola ogni numero, così un conto sbagliato salta all'occhio invece di
sopravvivere alla rilettura.

  1. LA SOGLIA E IL SUO COSTO. Dalla formula C_FP/(C_FP + C_FN) al costo
     totale annuo, confrontato con le due baseline di business e con la
     soglia di default 0.5.
  2. COSTRUIRE, COMPRARE O NON FARE. Il risparmio dell'esercizio 1 diventa
     un valore netto, e il prezzo di pareggio del fornitore esterno si
     calcola in forma chiusa.

DOMINIO (simulato)
------------------
La rete idrica di un piccolo comune: 1000 tratti di tubazione, un modello
che stima la probabilità che un tratto perda, una squadra che va a
verificare. Tutti i numeri sono inventati per l'esercizio e scelti perché
i conti vengano interi; non descrivono nessun acquedotto reale.

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

Il file gira anche così com'è: stampa quali esercizi mancano ancora.
Completa i TODO uno alla volta, in qualunque ordine, e rilancia. Le
soluzioni di riferimento sono commentate in fondo.

AMBIENTE DICHIARATO
-------------------
Eseguito con Python 3.14.4. Nessuna dipendenza esterna: si usa solo
`fractions` della libreria standard. Le probabilità sono frazioni esatte e
i costi vengono interi, quindi non c'è nessun motivo di introdurre la
virgola mobile e i suoi arrotondamenti.
"""

from fractions import Fraction


# ----------------------------------------------------------------------
# I parametri del problema (tutti INVENTATI, da sostituire con i tuoi).
#
# C_FP: una squadra esce, scava, non trova niente.
# C_FN: una perdita non segnalata diventa una rottura - acqua persa, scavo
#       d'urgenza, strada chiusa.
# ----------------------------------------------------------------------

C_FP = 150        # euro, falso positivo: ispezione a vuoto
C_FN = 1350       # euro, falso negativo: perdita che degenera

# Il modello è calibrato e raggruppa i 1000 tratti in quattro fasce di
# rischio: (probabilità di perdita, quanti tratti).
FASCE = (
    (Fraction(2, 100), 600),
    (Fraction(10, 100), 200),
    (Fraction(25, 100), 160),
    (Fraction(60, 100), 40),
)


# ----------------------------------------------------------------------
# ESERCIZIO 1 - La soglia e il suo costo
#
# La formula della soglia ottimale è derivata nell'articolo. Qui la si
# applica e, soprattutto, si misura che cosa costa NON applicarla.
#
# Domande a cui rispondere a parole dopo aver visto le tabelle:
#   - quanto costa, in euro all'anno, usare la soglia di default 0.5?
#   - il modello batte entrambe le baseline di business? Di quanto?
# ----------------------------------------------------------------------

def soglia_ottimale(costo_fp: int, costo_fn: int) -> Fraction:
    """La probabilità oltre la quale conviene mandare la squadra.

    Parametri
    ---------
    costo_fp : int, euro di un'ispezione a vuoto.
    costo_fn : int, euro di una perdita non segnalata.

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

    Suggerimento: è il punto in cui il costo atteso dell'azione eguaglia
    quello dell'inazione. La derivazione completa è nell'articolo; qui
    serve solo il risultato, ed è un rapporto fra i due costi.
    """
    # TODO (esercizio 1, prima parte): sostituisci `return None`.
    return None


def costo_annuo(soglia: Fraction) -> Fraction:
    """Costo atteso di un anno di esercizio con una data soglia di azione.

    Si manda la squadra su tutti i tratti la cui probabilità stimata è
    maggiore o uguale alla soglia.

    Parametri
    ---------
    soglia : Fraction fra 0 e 1.

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

    Suggerimento: su una fascia (p, n) dove si agisce, i tratti sani sono
    n*(1-p) e sono tutti falsi positivi; su una fascia dove non si agisce, i
    tratti che perdono sono n*p e sono tutti falsi negativi. I tratti
    indovinati non costano nulla, per l'ipotesi semplificatrice del modello.
    """
    # TODO (esercizio 1, seconda parte): sostituisci `return None`.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 2 - Costruire, comprare o non fare
#
# Il risparmio dell'esercizio 1 è il MASSIMO che questa iniziativa può
# valere. Quanto ne resta dopo aver pagato chi la fa funzionare?
#
#   COSTRUIRE: 30.000 euro di sviluppo, ammortizzati su 3 anni, più 18.000
#              euro all'anno fra infrastruttura, monitoraggio e
#              riaddestramento.
#   COMPRARE : un fornitore chiede un canone mensile e, poiché il suo
#              modello non è tarato sulla rete del comune, porta a casa
#              solo l'80% del risparmio.
#
# Domande:
#   - quale delle due opzioni ha valore netto positivo?
#   - a quale canone mensile il fornitore diventerebbe indifferente?
# ----------------------------------------------------------------------

SVILUPPO = 30_000          # INVENTATO: euro una tantum
ANNI_AMMORTAMENTO = 3      # INVENTATO
ESERCIZIO_ANNUO = 18_000   # INVENTATO: euro/anno di gestione
QUOTA_FORNITORE = Fraction(8, 10)    # INVENTATO: quanto risparmio cattura
CANONE_MENSILE = 4_500     # INVENTATO: euro/mese chiesti dal fornitore


def valore_netto(risparmio_annuo: Fraction, quota_catturata: Fraction,
                 costo_annuo_opzione: Fraction) -> Fraction:
    """Valore netto annuo di un'opzione, in euro.

    Parametri
    ---------
    risparmio_annuo     : Fraction, quanto fa risparmiare il modello ideale.
    quota_catturata     : Fraction fra 0 e 1, quanta parte ne cattura
                          davvero questa opzione.
    costo_annuo_opzione : Fraction, quanto costa all'anno questa opzione.

    Ritorna
    -------
    Fraction (euro/anno), oppure None se l'esercizio non è ancora svolto.

    Suggerimento: è la formula dell'articolo, con in più il fattore che
    tiene conto del fatto che nessuna opzione cattura il 100% del teorico.
    """
    # TODO (esercizio 2, prima parte): sostituisci `return None`.
    return None


def canone_di_pareggio(risparmio_annuo: Fraction,
                       quota_catturata: Fraction) -> Fraction:
    """Il canone MENSILE che azzera il valore netto dell'opzione "comprare".

    Ritorna
    -------
    Fraction (euro/mese), oppure None se l'esercizio non è ancora svolto.

    Suggerimento: imponi valore netto nullo e dividi per dodici. Nessuna
    ricerca a tentoni.
    """
    # TODO (esercizio 2, seconda parte): sostituisci `return None`.
    return None


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

def _sezione(titolo: str) -> None:
    print()
    print(titolo)
    print("-" * len(titolo))


def _da_fare(numero: int, dettaglio: str = "") -> None:
    coda = f" ({dettaglio})" if dettaglio else ""
    print(f"  [ ] Esercizio {numero} non ancora svolto{coda}: completa il TODO.")


def _euro(x: Fraction) -> str:
    return f"{float(x):>10,.0f}".replace(",", ".")


def perdite_attese() -> Fraction:
    """Quanti tratti ci si aspetta che perdano, in un anno. Già scritta."""
    return sum(p * n for p, n in FASCE)


def baseline() -> dict:
    """Le due baseline di business, senza modello. Già scritte."""
    perdite = perdite_attese()
    return {
        "non fare nulla": perdite * C_FN,
        "ispezionare tutto": (sum(n for _, n in FASCE) - perdite) * C_FP,
    }


def esegui_esercizio_1():
    _sezione("Esercizio 1 - la soglia e il suo costo")
    print(f"  Tratti: {sum(n for _, n in FASCE)} | perdite attese in un anno:"
          f" {perdite_attese()}")
    print("  fascia p | tratti | perdite attese | sani")
    for p, n in FASCE:
        print(f"  {float(p):8.2f} | {n:6d} | {float(p * n):14.0f} |"
              f" {float((1 - p) * n):4.0f}")

    s = soglia_ottimale(C_FP, C_FN)
    if s is None:
        _da_fare(1, "soglia_ottimale")
        return None
    print(f"  Soglia ottimale con C_FP = {C_FP} e C_FN = {C_FN}:"
          f" {s} = {float(s):.4f}")
    print("  C_FN | soglia esatta | soglia")
    for c_fn in (450, 1350, 2850):
        sx = soglia_ottimale(C_FP, c_fn)
        print(f"  {c_fn:4d} | {str(sx):>13} | {float(sx):.4f}")

    if costo_annuo(s) is None:
        _da_fare(1, "costo_annuo")
        return None
    print()
    print("  soglia | tratti ispezionati | FP | FN | costo annuo")
    for prova in (s, Fraction(1, 4), Fraction(1, 2)):
        ispezionati = sum(n for p, n in FASCE if p >= prova)
        fp = sum((1 - p) * n for p, n in FASCE if p >= prova)
        fn = sum(p * n for p, n in FASCE if p < prova)
        print(f"  {float(prova):6.2f} | {ispezionati:18d} | {float(fp):3.0f} |"
              f" {float(fn):2.0f} | {_euro(costo_annuo(prova))}")
    scarto = costo_annuo(Fraction(1, 2)) - costo_annuo(s)
    print(f"  La soglia di default 0.5 costa {_euro(scarto).strip()} euro"
          f" all'anno in più della soglia giusta.")

    print()
    print("  Baseline senza modello:")
    for nome, costo in baseline().items():
        print(f"    {nome:<18}: {_euro(costo)} euro")
    migliore = min(baseline().values())
    print(f"    modello alla soglia {float(s):.2f}: {_euro(costo_annuo(s))} euro")
    print(f"  Risparmio sulla migliore baseline:"
          f" {_euro(migliore - costo_annuo(s)).strip()} euro all'anno.")
    return migliore - costo_annuo(s)


def esegui_esercizio_2(risparmio) -> bool:
    _sezione("Esercizio 2 - costruire, comprare o non fare")
    if risparmio is None:
        print("  [ ] Serve prima l'esercizio 1: il risparmio annuo è il suo")
        print("      risultato. Completa quei TODO e rilancia.")
        return False
    if valore_netto(Fraction(1), Fraction(1), Fraction(0)) is None:
        _da_fare(2, "valore_netto")
        return False

    costo_build = Fraction(SVILUPPO, ANNI_AMMORTAMENTO) + ESERCIZIO_ANNUO
    opzioni = (
        ("costruire", Fraction(1), costo_build),
        ("comprare", QUOTA_FORNITORE, Fraction(CANONE_MENSILE * 12)),
        ("non fare", Fraction(0), Fraction(0)),
    )
    print(f"  Risparmio teorico annuo: {_euro(risparmio).strip()} euro")
    print("  opzione   | quota catturata | risparmio reale | costo annuo |"
          " valore netto")
    for nome, quota, costo in opzioni:
        vn = valore_netto(risparmio, quota, costo)
        print(f"  {nome:<9} | {float(quota):15.2f} |"
              f" {_euro(quota * risparmio)} | {_euro(costo)} | {_euro(vn)}")
    scelta = max(opzioni, key=lambda o: valore_netto(risparmio, o[1], o[2]))
    print(f"  Scelta con valore netto massimo: {scelta[0]}.")

    pareggio = canone_di_pareggio(risparmio, QUOTA_FORNITORE)
    if pareggio is None:
        _da_fare(2, "canone_di_pareggio")
        return False
    print(f"  Canone mensile di pareggio del fornitore: {pareggio} ="
          f" {float(pareggio):.2f} euro/mese,"
          f" contro i {CANONE_MENSILE} chiesti.")
    controllo = valore_netto(risparmio, QUOTA_FORNITORE, pareggio * 12)
    print(f"  Controllo: a quel canone il valore netto è {float(controllo):.2f}"
          f" -> {'OK' if controllo == 0 else 'INCOERENTE'}")
    print("  Il fornitore viene scartato per 100 euro al mese: una trattativa,")
    print("  non un verdetto tecnico. È il tipo di numero che va portato in")
    print("  riunione, perché sposta la discussione dal modello al prezzo.")
    return True


def main() -> None:
    print("Corso MLE - Modulo 00, Lezione 4 - esercizi di ragionamento")
    print("Dominio simulato: perdite nella rete idrica di un piccolo comune.")
    risparmio = esegui_esercizio_1()
    fatti = (risparmio is not None) + bool(esegui_esercizio_2(risparmio))
    print()
    print(f"Esercizi completati: {fatti} su 2.")
    if fatti < 2:
        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.
# ======================================================================
#
# --- Esercizio 1 -------------------------------------------------------
# def soglia_ottimale(costo_fp, costo_fn):
#     return Fraction(costo_fp, costo_fp + costo_fn)
#
# def costo_annuo(soglia):
#     totale = Fraction(0)
#     for p, n in FASCE:
#         if p >= soglia:
#             totale += n * (1 - p) * C_FP      # i sani ispezionati a vuoto
#         else:
#             totale += n * p * C_FN            # le perdite lasciate correre
#     return totale
#
# Atteso: soglia 150/1500 = 1/10. Con C_FN = 450 la soglia sale a 1/4, con
# C_FN = 2850 scende a 1/20: più cara è la rottura, più basso è il
# sospetto che basta a far uscire la squadra.
# Costi annui: 63.600 euro alla soglia 0.10, 99.600 alla soglia 0.5. La
# soglia di default costa 36.000 euro all'anno in più, e non perché il
# modello sia peggiore - è lo stesso modello. Cambia solo dove si taglia.
# Guarda la riga della soglia 0.25: costa anche lei 63.600 euro, esattamente
# quanto la 0.10. Non è una coincidenza ed è la verifica più pulita della
# formula: la fascia che le due soglie trattano in modo diverso è proprio
# quella a p = 1/10, e lì agire costa 200 x 0.9 x 150 = 27.000 euro mentre
# non agire costa 200 x 0.1 x 1350 = 27.000. Alla soglia ottimale le due
# azioni si equivalgono per definizione - è il punto in cui la si è
# trovata. È anche il motivo per cui, intorno al minimo, la curva di costo
# è piatta: sbagliare la soglia di poco non si paga quasi; sbagliarla di
# molto, come fa il default 0.5, si paga tutto.
# Baseline: "non fare nulla" 96 x 1350 = 129.600 euro; "ispezionare tutto"
# 904 x 150 = 135.600 euro. Il modello alla soglia giusta risparmia 66.000
# euro all'anno sulla migliore delle due.
# Nota su un numero facile da sbagliare: le perdite attese sono 96 su 1000
# tratti, cioè il 9.6%, ma la fascia più rischiosa (60%) ne contiene solo
# 24. Il grosso del danno (12 + 20 = 32 perdite) sta nelle due fasce basse,
# che sono enormi. È il motivo per cui la soglia finisce così in basso.
#
# --- Esercizio 2 -------------------------------------------------------
# def valore_netto(risparmio_annuo, quota_catturata, costo_annuo_opzione):
#     return quota_catturata * risparmio_annuo - costo_annuo_opzione
#
# def canone_di_pareggio(risparmio_annuo, quota_catturata):
#     return quota_catturata * risparmio_annuo / 12
#
# Atteso: costruire costa 30.000/3 + 18.000 = 28.000 euro all'anno e cattura
# tutto il risparmio, quindi vale 66.000 - 28.000 = +38.000 euro all'anno.
# Comprare cattura l'80% (52.800 euro) e costa 4.500 x 12 = 54.000: valore
# netto -1.200 euro, negativo. "Non fare" vale esattamente 0, ed è la
# baseline contro cui vanno misurate le altre due.
# Il canone di pareggio è 52.800/12 = 4.400 euro al mese: il fornitore è
# fuori per 100 euro al mese. Due letture, entrambe utili. La prima: la
# decisione non è "il modello è buono", è "a questo prezzo no" - e si
# tratta. La seconda: il valore netto di "comprare" dipende quasi tutto da
# quel 80% inventato. Rifai il conto con il 90% (59.400 - 54.000 = +5.400) e
# l'opzione torna positiva. Prima di firmare, misura la quota vera su un
# pilota: è il parametro più incerto e il più decisivo.
