Salta al contenuto principale

03 - AI Underwriting: Feature Engineering e Risk Scoring

AI Underwriting: Feature Engineering e Risk Scoring nell'Assicurazione Moderna L'underwriting e il cuore pulsante di qualsiasi compagnia assicurativa: e...

Cosa ne pensi di questo articolo?

03 - AI Underwriting: Feature Engineering e Risk Scoring
InsurTechFoto:SiddePexels

In Breve

AI Underwriting: Feature Engineering e Risk Scoring nell'Assicurazione Moderna L'underwriting e il cuore pulsante di qualsiasi compagnia assicurativa: e...

AI Underwriting: Feature Engineering e Risk Scoring nell'Assicurazione Moderna

L'underwriting e il cuore pulsante di qualsiasi compagnia assicurativa: e il processo con cui si decide se accettare un rischio, a quale prezzo e con quali condizioni. Per decenni, questo processo ha dipeso dall'esperienza di underwriter umani che analizzavano documenti cartacei e applicavano regole attuariali codificate in tabelle. Il risultato? Decisioni in 3-5 giorni lavorativi, costi operativi elevati e variabilità soggettiva tra gli underwriter.

L'intelligenza artificiale sta riscrivendo queste regole in modo radicale. Secondo McKinsey, gli investimenti globali in soluzioni AI per l'assicurazione supereranno i 6 miliardi di dollari nel 2025, con BCG che stima che il 36% del valore totale dell'AI assicurativa si concentra proprio nella funzione di underwriting. I numeri operativi sono altrettanto impressionanti: il tempo medio di decisione underwriting e sceso da 3-5 giorni a 12,4 minuti per le polizze standard, mantenendo un tasso di accuratezza nella valutazione del rischio del 99,3%.

Ma come funziona concretamente un sistema di AI underwriting? Questa guida decostruisce l'intero stack tecnico: dalla raccolta e ingegnerizzazione delle feature, ai modelli di risk scoring, fino all'interpretabilita e alla gestione del bias - con esempi di codice reali pronti per la produzione.

Cosa Imparerai

  • Architettura di un sistema AI underwriting end-to-end
  • Feature engineering specifico per il dominio assicurativo
  • Modelli ML per risk scoring: XGBoost, due stadi frequenza/severita
  • Interpretabilita con SHAP per decisioni auditabili e compliance-ready
  • Bias detection e fairness mitigation nel contesto normativo EU
  • MLOps per modelli underwriting in produzione con MLflow
  • Data drift monitoring con Population Stability Index (PSI)

Il Processo di Underwriting: da Legacy ad AI-Native

Prima di progettare un sistema AI, e essenziale capire il workflow tradizionale che stiamo automatizzando. Il processo di underwriting si articola in quattro fasi fondamentali:

  1. Raccolta informazioni: Il richiedente fornisce dati su se stesso e sul rischio (questionario, documenti, eventuale ispezione fisica del bene)
  2. Analisi del rischio: L'underwriter valuta la probabilità e la severita di eventuali sinistri futuri
  3. Pricing: Determinazione del premio in base al rischio valutato e agli obiettivi di combined ratio del portfolio
  4. Decisione: Accettazione, rifiuto o accettazione con condizioni (esclusioni, franchise, sovrappremio)

Un sistema AI-native non elimina queste fasi, ma le trasforma profondamente: la raccolta dati diventa automatica da fonti eterogenee (open data, telematica, credit bureau), l'analisi del rischio e eseguita da modelli ML in millisecondi, il pricing e dinamico e personalizzato per ciascun richiedente, e la decisione e automatizzata per i casi standard con supervisione umana per i casi complessi o borderline.

Quadro Normativo: AI Act EU e Underwriting

L'AI Act europeo (pienamente in vigore da agosto 2027) classifica i sistemi di scoring creditizio e assicurativo come AI ad alto rischio (Allegato III). Questo implica obblighi specifici: trasparenza delle decisioni automatizzate, diritto di revisione umana, documentazione tecnica dettagliata e valutazione della conformità ante-mercato. Il design dei sistemi di AI underwriting deve incorporare questi requisiti fin dall'architettura, non come retrofit successivo.

Feature Engineering per l'Underwriting Assicurativo

La qualità del feature engineering e il fattore che più differenzia un modello di underwriting eccellente da uno mediocre. A differenza di domini come la computer vision, dove le feature sono estratte automaticamente da layer convoluzionali, i dati tabulari assicurativi richiedono un'ingegneria manuale profonda basata sulla conoscenza attuariale del dominio.

Le feature per il ramo auto si raggruppano in cinque categorie principali:

  • Feature demografiche: eta, stato civile, tipo di residenza
  • Feature di guida: anni di patente, eta al primo conseguimento, storico sinistri e infrazioni
  • Feature veicolo: marca, modello, anno, valore, potenza, anno di immatricolazione
  • Feature geografiche: densita urbana, indice di criminalita dell'area, rischio meteo
  • Feature economiche: credit score, tipo di contratto assicurativo richiesto
import pandas as pd
import numpy as np
from typing import Dict, Optional
from dataclasses import dataclass
from datetime import date


@dataclass
class PolicyApplicant:
    """Rappresenta i dati grezzi di un richiedente polizza auto."""
    applicant_id: str
    birth_date: date
    license_date: date
    zip_code: str
    vehicle_make: str
    vehicle_year: int
    vehicle_value: float
    annual_mileage: int
    claims_3yr: int
    violations_3yr: int
    credit_score: Optional[int] = None
    marital_status: str = "single"
    housing_type: str = "tenant"


class AutoInsuranceFeatureEngineer:
    """
    Feature engineering per underwriting auto.
    Produce 40+ feature da dati grezzi del richiedente,
    includendo feature derivate, interazioni e encoding
    domain-specific.
    """

    VEHICLE_MAKE_RISK: Dict[str, int] = {
        "Ferrari": 5, "Lamborghini": 5, "Porsche": 4,
        "BMW": 3, "Mercedes": 3, "Audi": 3,
        "Toyota": 1, "Honda": 1, "Volkswagen": 2,
        "Ford": 2, "Fiat": 2, "Renault": 2,
    }

    def __init__(self, reference_date: Optional[date] = None):
        self.reference_date = reference_date or date.today()

    def engineer_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        features: Dict[str, float] = {}
        features.update(self._demographic_features(applicant))
        features.update(self._driving_experience_features(applicant))
        features.update(self._vehicle_features(applicant))
        features.update(self._claims_features(applicant))
        features.update(self._geographic_features(applicant))
        if applicant.credit_score is not None:
            features.update(self._credit_features(applicant))
        features.update(self._interaction_features(features))
        return features

    def _demographic_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        age = (self.reference_date - applicant.birth_date).days / 365.25
        return {
            "age": age,
            "age_squared": age ** 2,
            "age_under_25": float(age < 25),
            "age_over_70": float(age > 70),
            "age_risk_young": max(0.0, (25 - age) / 25) if age < 25 else 0.0,
            "age_risk_senior": max(0.0, (age - 70) / 20) if age > 70 else 0.0,
            "is_married": float(applicant.marital_status == "married"),
            "is_homeowner": float(applicant.housing_type == "owner"),
        }

    def _driving_experience_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        years_licensed = (self.reference_date - applicant.license_date).days / 365.25
        age = (self.reference_date - applicant.birth_date).days / 365.25
        age_at_license = age - years_licensed
        return {
            "years_licensed": years_licensed,
            "years_licensed_squared": years_licensed ** 2,
            "age_at_first_license": age_at_license,
            "late_license_ratio": max(0.0, (age_at_license - 18) / 10),
            "is_new_driver": float(years_licensed < 2),
            "is_experienced_driver": float(years_licensed > 10),
        }

    def _vehicle_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        vehicle_age = self.reference_date.year - applicant.vehicle_year
        make_risk = self.VEHICLE_MAKE_RISK.get(applicant.vehicle_make, 2)
        return {
            "vehicle_age": float(vehicle_age),
            "vehicle_value": applicant.vehicle_value,
            "vehicle_value_log": np.log1p(applicant.vehicle_value),
            "vehicle_make_risk_score": float(make_risk),
            "is_high_performance": float(make_risk >= 4),
            "is_new_vehicle": float(vehicle_age <= 2),
            "is_old_vehicle": float(vehicle_age > 10),
            "annual_mileage": float(applicant.annual_mileage),
            "annual_mileage_log": np.log1p(applicant.annual_mileage),
            "high_mileage": float(applicant.annual_mileage > 20000),
        }

    def _claims_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        claims = applicant.claims_3yr
        violations = applicant.violations_3yr
        return {
            "claims_3yr": float(claims),
            "violations_3yr": float(violations),
            "has_any_claim": float(claims > 0),
            "has_multiple_claims": float(claims > 1),
            "has_violations": float(violations > 0),
            # Score combinato ponderato: sinistri pesano 3x rispetto a infrazioni
            "incident_score": claims * 3.0 + violations * 1.5,
            "claims_x_violations": float(claims * violations),
        }

    def _geographic_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        # In produzione: lookup su DB geografici (ISTAT, OpenStreetMap, criminalita)
        zip_hash = hash(applicant.zip_code) % 100
        urban_score = (zip_hash % 5) / 4.0
        crime_index = (zip_hash % 3) / 2.0
        weather_risk = (zip_hash % 4) / 3.0
        return {
            "urban_density_score": urban_score,
            "area_crime_index": crime_index,
            "area_weather_risk": weather_risk,
            "composite_geo_risk": (urban_score + crime_index + weather_risk) / 3,
        }

    def _credit_features(self, applicant: PolicyApplicant) -> Dict[str, float]:
        score = applicant.credit_score or 0
        return {
            "credit_score": float(score),
            "credit_score_normalized": (score - 300) / (850 - 300),
            "poor_credit": float(score < 580),
            "fair_credit": float(580 <= score < 670),
            "good_credit": float(670 <= score < 740),
            "excellent_credit": float(score >= 740),
        }

    def _interaction_features(self, features: Dict[str, float]) -> Dict[str, float]:
        return {
            # Giovane + auto sportiva = rischio molto alto
            "young_high_perf": (
                features.get("age_risk_young", 0) *
                features.get("is_high_performance", 0)
            ),
            # Sinistri + area ad alto crimine amplificano il rischio
            "claims_urban": (
                features.get("claims_3yr", 0) *
                features.get("urban_density_score", 0)
            ),
            # Mileage alto + veicolo vecchio = rischio meccanico aumentato
            "mileage_old_vehicle": (
                features.get("annual_mileage_log", 0) *
                features.get("is_old_vehicle", 0)
            ),
        }

Modelli di Risk Scoring: Approcci e Trade-off

La scelta del modello di machine learning per il risk scoring deve bilanciare accuratezza predittiva, interpretabilita (fondamentale per compliance), velocità di inferenza e facilità di manutenzione. Ecco i principali approcci dell'industria assicurativa:

Confronto Modelli per Insurance Risk Scoring

Modello Accuratezza Interpretabilita Use Case Ideale
GLM (Poisson/Gamma) Media Molto Alta Baseline attuariale, regulatory acceptance
Random Forest Alta Media Feature importance, robustezza agli outlier
XGBoost / LightGBM Molto Alta Media Produzione standard, SOTA su dati tabulari
Neural Network Tabulare Alta Bassa Feature complesse con embedding categoriali

L'approccio più consolidato nell'industria e il modello a due stadi: un modello di frequenza (probabilità di avere almeno un sinistro) e uno di severita (costo atteso del sinistro dato che si verifica). Il premio puro atteso e: Frequenza x Severita.

import xgboost as xgb
from sklearn.metrics import mean_absolute_error, mean_squared_error
import numpy as np
import pandas as pd
from typing import Dict, Optional


class TwoStageRiskScorer:
    """
    Modello a due stadi per pricing assicurativo auto.

    Stage 1: Frequency model (Poisson regression con XGBoost)
             Target = numero sinistri per polizza
    Stage 2: Severity model (Tweedie/Gamma con XGBoost)
             Target = importo sinistro, addestrato solo su polizze con sinistri

    Pure Premium = E[Frequency] * E[Severity | has_claim]
    """

    FREQUENCY_PARAMS: Dict = {
        "objective": "count:poisson",
        "eval_metric": "poisson-nloglik",
        "max_depth": 6,
        "learning_rate": 0.05,
        "n_estimators": 500,
        "min_child_weight": 50,  # stabilità attuariale: min sinistri per leaf
        "subsample": 0.8,
        "colsample_bytree": 0.8,
        "reg_alpha": 0.1,
        "reg_lambda": 1.0,
        "tree_method": "hist",
        "early_stopping_rounds": 50,
    }

    SEVERITY_PARAMS: Dict = {
        "objective": "reg:tweedie",
        "tweedie_variance_power": 1.5,  # 1=Poisson, 2=Gamma
        "eval_metric": "tweedie-nloglik@1.5",
        "max_depth": 5,
        "learning_rate": 0.05,
        "n_estimators": 300,
        "min_child_weight": 30,
        "subsample": 0.8,
        "colsample_bytree": 0.7,
        "reg_alpha": 0.1,
        "reg_lambda": 1.0,
        "tree_method": "hist",
        "early_stopping_rounds": 30,
    }

    def __init__(self) -> None:
        self.frequency_model = xgb.XGBRegressor(**self.FREQUENCY_PARAMS)
        self.severity_model = xgb.XGBRegressor(**self.SEVERITY_PARAMS)
        self.feature_names: list = []

    def fit(
        self,
        X: pd.DataFrame,
        y_claims: pd.Series,
        y_amounts: pd.Series,
        exposure: pd.Series,
        eval_fraction: float = 0.2,
    ) -> "TwoStageRiskScorer":
        """
        Addestra entrambi i modelli.

        IMPORTANTE: usa split temporale, non random shuffle.
        I dati assicurativi sono autocorrelati nel tempo.
        """
        self.feature_names = X.columns.tolist()
        split_idx = int(len(X) * (1 - eval_fraction))

        X_train, X_val = X.iloc[:split_idx], X.iloc[split_idx:]
        freq_train = y_claims.iloc[:split_idx]
        freq_val = y_claims.iloc[split_idx:]

        # Stage 1: Frequency
        self.frequency_model.fit(
            X_train, freq_train,
            sample_weight=exposure.iloc[:split_idx],
            eval_set=[(X_val, freq_val)],
            verbose=50,
        )

        # Stage 2: Severity - solo su polizze con sinistri
        has_claim = y_amounts > 0
        X_sev = X[has_claim]
        y_sev = y_amounts[has_claim]
        sev_split = int(len(X_sev) * (1 - eval_fraction))

        self.severity_model.fit(
            X_sev.iloc[:sev_split], y_sev.iloc[:sev_split],
            eval_set=[(X_sev.iloc[sev_split:], y_sev.iloc[sev_split:])],
            verbose=30,
        )
        return self

    def predict_pure_premium(
        self, X: pd.DataFrame, exposure: float = 1.0
    ) -> np.ndarray:
        """Calcola il pure premium: E[Freq] * E[Severity]."""
        freq = self.frequency_model.predict(X) * exposure
        sev = self.severity_model.predict(X)
        return freq * sev

    def evaluate(self, X: pd.DataFrame, y_claims: pd.Series) -> Dict[str, float]:
        pred = self.frequency_model.predict(X)
        mae = mean_absolute_error(y_claims, pred)
        rmse = float(np.sqrt(mean_squared_error(y_claims, pred)))
        gini = self._gini_coefficient(y_claims.values, pred)
        lift = self._lift_at_decile(y_claims.values, pred, 0.1)
        return {
            "mae": round(mae, 6),
            "rmse": round(rmse, 6),
            "gini_coefficient": round(gini, 4),
            "lift_top_decile": round(lift, 4),
        }

    def _gini_coefficient(self, actual: np.ndarray, predicted: np.ndarray) -> float:
        """Gini coefficient: metrica attuariale standard per modelli di frequenza."""
        idx = np.argsort(predicted)
        cum = np.cumsum(actual[idx])
        cum_norm = cum / cum[-1]
        n = len(actual)
        lorenz_area = float(np.sum(cum_norm)) / n
        return 2 * (lorenz_area - 0.5)

    def _lift_at_decile(
        self, actual: np.ndarray, predicted: np.ndarray, decile: float
    ) -> float:
        k = max(1, int(len(actual) * decile))
        top_idx = np.argsort(predicted)[-k:]
        base_rate = actual.mean()
        if base_rate == 0:
            return 0.0
        return float(actual[top_idx].mean() / base_rate)

Interpretabilita con SHAP: Decisioni Auditabili

In un contesto regolamentato come quello assicurativo, un modello black-box non e sufficiente. La normativa richiede che le decisioni di underwriting siano spiegabili: per il cliente (diritto alla spiegazione GDPR), per gli underwriter (revisione dei casi borderline) e per i regulators (Solvency II Pillar 3, ORSA). SHAP (SHapley Additive exPlanations) e lo strumento di riferimento dell'industria per l'interpretabilita post-hoc di modelli ensemble.

import shap
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple


class UnderwritingExplainer:
    """
    Spiegazioni SHAP per decisioni underwriting.
    Genera output a tre livelli: cliente, underwriter, compliance.
    """

    FEATURE_LABELS: Dict[str, str] = {
        "age": "eta del guidatore",
        "years_licensed": "anni di patente",
        "claims_3yr": "sinistri negli ultimi 3 anni",
        "violations_3yr": "infrazioni negli ultimi 3 anni",
        "vehicle_make_risk_score": "categoria rischio veicolo",
        "vehicle_age": "anzianita del veicolo",
        "vehicle_value": "valore del veicolo",
        "annual_mileage": "chilometraggio annuo dichiarato",
        "composite_geo_risk": "rischio della zona geografica",
        "credit_score": "score creditizio",
        "young_high_perf": "combinazione giovane + veicolo sportivo",
    }

    def __init__(self, model, feature_names: List[str]) -> None:
        self.feature_names = feature_names
        self.explainer = shap.TreeExplainer(model)

    def explain(
        self, X_row: pd.DataFrame, risk_score: float
    ) -> Dict:
        """Spiegazione completa per una singola valutazione."""
        shap_values = self.explainer.shap_values(X_row)

        impacts: List[Tuple[str, float]] = sorted(
            zip(self.feature_names, shap_values[0]),
            key=lambda x: abs(x[1]),
            reverse=True
        )

        return {
            "risk_score": round(risk_score, 2),
            "decision": self._score_to_decision(risk_score),
            "customer_message": self._customer_message(impacts, risk_score),
            "top_risk_factors": [
                {
                    "name": name,
                    "label": self.FEATURE_LABELS.get(name, name),
                    "direction": "aumenta rischio" if shap > 0 else "riduce rischio",
                    "magnitude": round(abs(shap), 4),
                }
                for name, shap in impacts[:5]
            ],
            "audit_trail": {
                "base_expected_value": float(self.explainer.expected_value),
                "all_shap_values": {
                    n: round(float(s), 6)
                    for n, s in zip(self.feature_names, shap_values[0])
                },
                "input_features": X_row.to_dict(orient="records")[0],
            },
        }

    def _customer_message(
        self, impacts: List[Tuple[str, float]], score: float
    ) -> str:
        high = [(n, v) for n, v in impacts if abs(v) > 0.1]
        if not high:
            return "Il tuo profilo rientra nella fascia di rischio standard."
        positivi = [self.FEATURE_LABELS.get(n, n) for n, v in high[:3] if v < 0]
        negativi = [self.FEATURE_LABELS.get(n, n) for n, v in high[:3] if v > 0]
        parts = []
        if negativi:
            parts.append(f"Fattori che aumentano il profilo di rischio: {', '.join(negativi)}.")
        if positivi:
            parts.append(f"Fattori a tuo favore: {', '.join(positivi)}.")
        return " ".join(parts)

    def _score_to_decision(self, score: float) -> str:
        if score < 30:
            return "ACCEPT_PREFERRED"
        elif score < 60:
            return "ACCEPT_STANDARD"
        elif score < 80:
            return "ACCEPT_SUBSTANDARD"
        return "DECLINE_OR_MANUAL_REVIEW"

Fairness e Bias Detection nel Contesto EU

L'uso di variabili proxy (codice postale, credit score) può introdurre discriminazioni indirette vietate dalla normativa. In Europa, la direttiva sull'uguaglianza di genere (confermata dalla sentenza Test-Achats della Corte di Giustizia UE del 2011) vieta l'uso del genere per il pricing assicurativo. L'AI Act aggiunge vincoli per i sistemi ad alto rischio classificati in Allegato III, richiedendo valutazioni di conformità obbligatorie prima del deployment.

import pandas as pd
import numpy as np
from sklearn.metrics import confusion_matrix
from typing import Dict, List


class FairnessAuditor:
    """
    Auditor di fairness per modelli underwriting (EU-compliant).

    Metriche implementate:
    - Disparate Impact (regola 80%)
    - Demographic Parity Gap
    - Equal Opportunity (TPR parity)
    - Calibration by group
    """

    DISPARATE_IMPACT_THRESHOLD = 0.8  # EEOC 80% rule
    MAX_DP_GAP = 0.1                   # linee guida EIOPA

    def __init__(
        self,
        predictions: np.ndarray,
        true_labels: np.ndarray,
        sensitive_df: pd.DataFrame,
    ) -> None:
        self.predictions = predictions
        self.true_labels = true_labels
        self.sensitive_df = sensitive_df

    def full_audit(self) -> Dict:
        results: Dict = {}

        for attr in self.sensitive_df.columns:
            groups = self.sensitive_df[attr].unique()
            attr_results: Dict = {}

            for group in groups:
                mask = self.sensitive_df[attr] == group
                g_pred = self.predictions[mask]
                g_true = self.true_labels[mask]

                attr_results[str(group)] = {
                    "count": int(mask.sum()),
                    "acceptance_rate": float((g_pred < 0.6).mean()),
                    "avg_score": round(float(g_pred.mean()), 4),
                    "tpr": self._tpr(g_true, g_pred),
                }

            di = self._disparate_impact(attr_results)
            dp = self._dp_gap(attr_results)

            attr_results["_metrics"] = {
                "disparate_impact": round(di, 4),
                "demographic_parity_gap": round(dp, 4),
                "passes_di_rule": di >= self.DISPARATE_IMPACT_THRESHOLD,
                "passes_dp_rule": dp <= self.MAX_DP_GAP,
                "overall_fair": di >= self.DISPARATE_IMPACT_THRESHOLD and dp <= self.MAX_DP_GAP,
            }
            results[attr] = attr_results

        return results

    def _tpr(self, labels: np.ndarray, preds: np.ndarray, thr: float = 0.5) -> float:
        if len(labels) < 10:
            return float("nan")
        binary = (preds >= thr).astype(int)
        try:
            tn, fp, fn, tp = confusion_matrix(labels, binary, labels=[0, 1]).ravel()
            return round(tp / (tp + fn), 4) if (tp + fn) > 0 else 0.0
        except ValueError:
            return float("nan")

    def _disparate_impact(self, groups: Dict) -> float:
        rates = [v["acceptance_rate"] for k, v in groups.items()
                 if not k.startswith("_") and isinstance(v, dict)]
        if not rates or max(rates) == 0:
            return 1.0
        return min(rates) / max(rates)

    def _dp_gap(self, groups: Dict) -> float:
        rates = [v["acceptance_rate"] for k, v in groups.items()
                 if not k.startswith("_") and isinstance(v, dict)]
        return (max(rates) - min(rates)) if rates else 0.0

MLOps e Monitoring in Produzione

I modelli di underwriting sono soggetti a concept drift frequente: il profilo dei richiedenti cambia (nuovi modelli di veicoli elettrici, variazioni demografiche), i costi di riparazione subiscono inflazione, eventi climatici estremi alterano i pattern di sinistro. Un sistema di monitoring continuo con Population Stability Index (PSI) e essenziale per identificare quando il modello deve essere retrainato.

from scipy import stats
import numpy as np
import pandas as pd
from typing import Dict, List
from datetime import datetime


class DriftMonitor:
    """
    Monitora data drift per modelli underwriting.
    Usa PSI (Population Stability Index) come metrica primaria.

    PSI interpretation:
    - PSI < 0.1:  Nessun cambiamento significativo
    - PSI 0.1-0.25: Cambiamento moderato, monitorare
    - PSI > 0.25:  Cambiamento significativo, retraining consigliato
    """

    def __init__(self, reference_df: pd.DataFrame, features: List[str]) -> None:
        self.reference_df = reference_df
        self.features = features

    def check_drift(self, current_df: pd.DataFrame) -> Dict:
        feature_results: Dict = {}
        critical_features = []

        for feat in self.features:
            if feat not in current_df.columns:
                continue

            psi = self._psi(self.reference_df[feat], current_df[feat])
            ks_stat, ks_p = stats.ks_2samp(
                self.reference_df[feat].dropna(),
                current_df[feat].dropna()
            )

            status = "ok" if psi < 0.1 else ("warning" if psi < 0.25 else "critical")

            feature_results[feat] = {
                "psi": round(psi, 4),
                "ks_statistic": round(ks_stat, 4),
                "ks_pvalue": round(ks_p, 4),
                "status": status,
            }

            if status == "critical":
                critical_features.append(feat)

        avg_psi = float(np.mean([v["psi"] for v in feature_results.values()]))

        return {
            "checked_at": datetime.now().isoformat(),
            "overall_psi": round(avg_psi, 4),
            "retraining_recommended": avg_psi > 0.1,
            "critical_features": critical_features,
            "feature_details": feature_results,
        }

    def _psi(self, ref: pd.Series, cur: pd.Series, bins: int = 10) -> float:
        ref_clean = ref.dropna().values
        cur_clean = cur.dropna().values
        edges = np.percentile(ref_clean, np.linspace(0, 100, bins + 1))
        edges = np.unique(edges)

        ref_counts, _ = np.histogram(ref_clean, bins=edges)
        cur_counts, _ = np.histogram(cur_clean, bins=edges)

        ref_pct = (ref_counts + 1e-10) / len(ref_clean)
        cur_pct = (cur_counts + 1e-10) / len(cur_clean)

        return float(np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct)))

Best Practices e Anti-pattern

Best Practices per AI Underwriting

  • Architettura due stadi (frequenza/severita): e lo standard attuariale e produce pricing più accurato rispetto a un modello singolo sull'importo sinistro
  • Split temporale obbligatorio: i dati assicurativi sono autocorrelati nel tempo; mai usare random shuffle per train/test split
  • Esposizione come offset: usare sempre la durata della polizza (exposure in anni) come offset nel modello Poisson per normalizzare il claim count
  • Mantieni un GLM baseline: i modelli lineari generalizzati sono più facilmente validabili dai regulators e forniscono benchmark per valutare il valore aggiunto del ML
  • Shadow mode prima del go-live: esegui il modello in parallelo all'underwriting umano per 30-90 giorni, confrontando le decisioni prima di automatizzare
  • Monitora PSI settimanalmente: il drift nel ramo auto e frequente a causa di nuovi modelli di veicoli, inflazione costi riparazione, variazioni normative

Anti-pattern da Evitare

  • Feature leakage: non usare mai variabili disponibili solo post-sinistro (claim amount, reserve) come feature di training del modello di frequenza
  • Ottimizzare solo AUC: nel ramo assicurativo le metriche rilevanti sono Gini coefficient, Combined Ratio e Lift nel top decile di rischio
  • Modelli con 500+ feature: impossibili da validare attuarialmente e da giustificare al regulator; preferire feature selection rigorosa (max 40-60 feature)
  • Ignorare la concentrazione del portfolio: un modello che accetta solo profili a bassissimo rischio crea anti-selezione e portfolio non bilanciato
  • Discriminazione proxy: variabili come il codice postale possono essere proxy di etnia; verificare sempre il disparate impact prima del deploy

Conclusioni e Next Steps

L'AI underwriting non sostituisce l'underwriter umano, ma lo amplifica: le decisioni per polizze standard (80-90% del volume) possono essere completamente automatizzate con accuratezza superiore alla media umana, liberando gli specialist per i casi complessi dove l'esperienza di dominio e insostituibile.

Le chiavi per un sistema di successo sono: feature engineering profondo basato su conoscenza attuariale, architettura a due stadi frequenza/severita, interpretabilita SHAP per compliance, audit di fairness obbligatorio e monitoring continuo con PSI per la gestione del drift.

Il prossimo articolo della serie esplora l'automazione dei sinistri con Computer Vision e NLP: dalla FNOL digitale all'assessment automatico dei danni fotografici, fino al settlement accelerato end-to-end.

Serie InsurTech Engineering

  • 01 - Dominio Assicurativo per Developer: Prodotti, Attori e Data Model
  • 02 - Policy Management Cloud-Native: Architettura API-First
  • 03 - Pipeline Telematica: Processing Dati UBI su Scala
  • 04 - AI Underwriting: Feature Engineering e Risk Scoring (questo articolo)
  • 05 - Automazione Sinistri: Computer Vision e NLP
  • 06 - Fraud Detection: Graph Analytics e Behavioral Signal
  • 07 - Standard ACORD e API Integration Assicurative
  • 08 - Compliance Engineering: Solvency II e IFRS 17

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti