spb/traite-evaluation-qc
Public
1"""Chapitre 37 — Apprentissage automatique et explicabilité : reproductibilité et bornes de performance des chiffres cités dans le2texte, les tableaux et les solutions. Modèle hédonique de référence : traite.cas1.hedonique (non redéfini). Tolérances déclarées."""3import sys, pathlib, math4import numpy as np, pytest5sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))6import ch37_ml as c377import ch18_hedonique as c188from traite import cas1910@pytest.fixture(scope="module")11def res():12 return c37.main(verbose=False)1314def test_plis_identiques_au_chapitre_18():15 """Les dix plis aléatoires sont exactement ceux de ch18_hedonique.validation_croisee (même graine, même découpage)."""16 v = cas1.charger()["ventes"]; pa = c37.plis_aleatoires(len(v))17 rng = np.random.default_rng(c18.GRAINE); attendu = np.array_split(rng.permutation(len(v)), 10)18 assert all(np.array_equal(a, b) for a, b in zip(pa, attendu)) and sum(len(f) for f in pa) == 60019 assert c37.GRAINE == c18.GRAINE == 202610312021def test_reproductibilite_et_reference(res):22 """Le modèle hédonique validé ici reproduit le ch. 18 : RMSE 40 888 $, COD 6,83 en validation croisée ; prédiction du sujet 637 953 $."""23 m = res["comparaison"]["metriques"]["Hédonique (référence)"]["aleatoire"]24 assert m["COD"] == pytest.approx(6.83, abs=0.01) and m["rmse_log"] == pytest.approx(0.0839, abs=0.0005) and m["PRD"] == pytest.approx(1.007, abs=0.001)25 assert m["mdape"] == pytest.approx(5.68, abs=0.05) and m["r2_hors"] == pytest.approx(0.846, abs=0.001)26 assert res["modele_ref"]["r2"] == pytest.approx(0.853, abs=0.001) and res["modele_ref"]["sigma"] == pytest.approx(0.083, abs=0.0005)27 s = res["sujet_pred"]28 assert s.loc["Hédonique (référence)", "prediction_cas1"] == pytest.approx(637_953, abs=1)29 assert s.loc["Hédonique (référence)", "ln_pred"] == pytest.approx(13.3627, abs=0.0001)30 # une seconde exécution des modèles stochastiques donne les mêmes prédictions (graine fixée)31 Xm = res["Xm"]; y = res["y"]; f = res["comparaison"]["plis"]["aleatoire"][0]; tr = np.setdiff1d(np.arange(600), f)32 p1 = c37._boosting().fit(Xm.iloc[tr], y[tr]).predict(Xm.iloc[f]); p2 = c37._boosting().fit(Xm.iloc[tr], y[tr]).predict(Xm.iloc[f])33 assert np.allclose(p1, p2) and np.allclose(p1, res["comparaison"]["pred"]["Gradient boosting"]["aleatoire"][f])3435def test_comparaison_aleatoire(res):36 """Sur un jeu engendré par une loi semi-log additive, l'hédonique bien spécifié domine ; le boosting est le meilleur des modèles d'arbres."""37 M = {k: v["aleatoire"] for k, v in res["comparaison"]["metriques"].items()}38 attendu = {"Hédonique (référence)": (0.0839, 6.83, 5.68), "Arbre (profondeur 3)": (0.1468, 11.63, 9.85), "Forêt aléatoire": (0.1067, 8.64, 7.52),39 "Gradient boosting": (0.0931, 7.42, 6.22), "XGBoost": (0.0921, 7.33, 6.09)}40 for nom, (rmse, cod, mdape) in attendu.items():41 assert M[nom]["rmse_log"] == pytest.approx(rmse, abs=0.0015), nom42 assert M[nom]["COD"] == pytest.approx(cod, abs=0.12), nom43 assert M[nom]["mdape"] == pytest.approx(mdape, abs=0.15), nom44 assert M["Hédonique (référence)"]["rmse_log"] < M["XGBoost"]["rmse_log"] < M["Gradient boosting"]["rmse_log"] < M["Forêt aléatoire"]["rmse_log"] < M["Arbre (profondeur 3)"]["rmse_log"]45 # bornes : tous les modèles d'ensemble restent dans la plage IAAO 5-10 ; l'arbre seul en sort46 assert 5.0 < M["Gradient boosting"]["COD"] < 10.0 and 5.0 < M["Forêt aléatoire"]["COD"] < 10.0 and M["Arbre (profondeur 3)"]["COD"] > 10.047 # régressivité des arbres : PRB plus négatif que l'hédonique, PRD plus grand48 assert M["Forêt aléatoire"]["PRB"] < -0.15 and M["Forêt aléatoire"]["PRD"] > M["Hédonique (référence)"]["PRD"]49 assert M["Forêt aléatoire"]["PRB"] == pytest.approx(-0.196, abs=0.01) and M["Gradient boosting"]["PRB"] == pytest.approx(-0.088, abs=0.01)50 # niveau : médianes des ratios ≈ 1 pour tous51 assert all(0.99 < m["ratio_median"] < 1.015 for m in M.values())52 # RMSE en dollars53 assert M["Hédonique (référence)"]["rmse_dollars"] == pytest.approx(40_889, abs=30) and M["Gradient boosting"]["rmse_dollars"] == pytest.approx(45_644, abs=300)5455def test_validation_spatiale_et_temporelle(res):56 M = res["comparaison"]["metriques"]57 assert M["Hédonique (référence)"]["spatial"]["COD"] == pytest.approx(7.00, abs=0.1) and M["Gradient boosting"]["spatial"]["COD"] == pytest.approx(7.62, abs=0.12)58 assert M["Forêt aléatoire"]["spatial"]["COD"] == pytest.approx(8.96, abs=0.12) and M["XGBoost"]["spatial"]["COD"] == pytest.approx(7.53, abs=0.12)59 # la validation spatiale est toujours plus sévère que la validation aléatoire60 for nom in M: assert M[nom]["spatial"]["rmse_log"] >= M[nom]["aleatoire"]["rmse_log"] - 1e-9, nom61 bloc = res["comparaison"]["plis"]["bloc_spatial"]; assert len(np.unique(bloc)) == 10 and np.bincount(bloc).min() >= 962 # temporel : 101 ventes de 2026 ; les arbres sous-prédisent (pas d'extrapolation de la tendance), l'hédonique presque pas63 T = {k: v["temporel"] for k, v in M.items()}64 assert T["Hédonique (référence)"]["n"] == 10165 assert T["Hédonique (référence)"]["biais_log"] == pytest.approx(-0.0102, abs=0.001) and T["Gradient boosting"]["biais_log"] == pytest.approx(-0.034, abs=0.004)66 assert T["Forêt aléatoire"]["biais_log"] == pytest.approx(-0.060, abs=0.005) and T["Arbre (profondeur 3)"]["biais_log"] == pytest.approx(-0.085, abs=0.006)67 assert T["Gradient boosting"]["ratio_median"] == pytest.approx(0.966, abs=0.005) and T["Hédonique (référence)"]["ratio_median"] == pytest.approx(0.988, abs=0.003)68 d = res["detendance"]69 assert d["metriques_detendance"]["biais_log"] == pytest.approx(-0.018, abs=0.004) and d["metriques_detendance"]["COD"] == pytest.approx(7.44, abs=0.15)70 assert d["metriques_feuille1"]["biais_log"] == pytest.approx(-0.134, abs=0.01) and d["n_entrainement_apres_mois_17"] == 1471 assert d["beta_mois_entrainement"] == pytest.approx(0.00342, abs=0.00005)72 # le biais du boosting brut s'aggrave au fil des mois de 202673 b = res["comparaison"]["biais_mensuel_2026"]["Gradient boosting"]; assert b[25.0] < b[19.0] and b[25.0] == pytest.approx(-0.076, abs=0.01)7475def test_un_secteur_exclu(res):76 S = res["secteurs"]77 assert S["metriques"]["Hédonique (référence)"]["COD"] == pytest.approx(10.44, abs=0.1) and S["metriques"]["Gradient boosting"]["COD"] == pytest.approx(8.59, abs=0.15)78 assert S["metriques"]["Forêt aléatoire"]["COD"] == pytest.approx(10.21, abs=0.15)79 bh = S["biais_secteur"]["Hédonique (référence)"]80 assert bh["Buckingham"] == pytest.approx(0.234, abs=0.005) and bh["Masson-Angers"] == pytest.approx(0.261, abs=0.005) and abs(bh["Gatineau"]) < 0.0281 # le biais de l'hédonique sur un secteur exclu est (au bruit près) la prime de secteur omise82 assert bh["Buckingham"] == pytest.approx(-c37.VRAIS["sect"]["Buckingham"], abs=0.06) and bh["Masson-Angers"] == pytest.approx(-c37.VRAIS["sect"]["Masson-Angers"], abs=0.06)83 bg = S["biais_secteur"]["Gradient boosting"]84 assert abs(bg["Buckingham"]) < 0.02 and bg["Masson-Angers"] == pytest.approx(0.072, abs=0.01) and bg["Gatineau"] == pytest.approx(-0.068, abs=0.01)8586def test_scenario_non_lineaire(res):87 """Perturbation déclarée : le boosting bat l'hédonique mal spécifié, et l'hédonique respécifié reprend l'avantage."""88 sc = res["scenario"]; M = sc["metriques"]89 assert sc["n_prime"] == 76 and sc["n_mur"] == 23590 assert M["Hédonique (référence)"]["COD"] == pytest.approx(8.15, abs=0.1) and M["Gradient boosting"]["COD"] == pytest.approx(7.46, abs=0.12)91 assert M["XGBoost"]["COD"] == pytest.approx(7.40, abs=0.12) and M["Hédonique respécifié"]["COD"] == pytest.approx(6.85, abs=0.1) and M["Forêt aléatoire"]["COD"] == pytest.approx(9.17, abs=0.15)92 assert M["Gradient boosting"]["rmse_log"] < M["Hédonique (référence)"]["rmse_log"] and M["Hédonique respécifié"]["rmse_log"] < M["Gradient boosting"]["rmse_log"]93 assert M["Hédonique (référence)"]["rmse_log"] == pytest.approx(0.1003, abs=0.001) and M["Gradient boosting"]["rmse_log"] == pytest.approx(0.0944, abs=0.0015)94 c = sc["coef_respecifie"]95 assert c["prime"] == pytest.approx(0.129, abs=0.001) and c["mur"] == pytest.approx(-0.092, abs=0.001) and c["sigma"] == pytest.approx(0.0831, abs=0.0005)96 assert abs(c["prime"] - c37.SCENARIO["prime_qualite_age"]) < 1.96 * c["se_prime"] and abs(c["mur"] - c37.SCENARIO["mur"]) < 1.96 * c["se_mur"]97 # l'interaction âge × qualité devient la première interaction SHAP dans le scénario, et n'apparaît pas en tête dans le cas de base98 assert res["shap"]["scenario_interactions"].index[0] == ("age", "qualite") and res["shap"]["scenario_interactions"].iloc[0] == pytest.approx(0.0068, abs=0.001)99 assert res["shap"]["interactions"].index[0] != ("age", "qualite") and res["shap"]["interactions"].iloc[0] < 0.004100101def test_shap(res):102 sh = res["shap"]; Xm = res["Xm"]103 assert sh["efficacite_max_err"] < 1e-9 and sh["efficacite_sujet_err"] < 1e-9 # axiome d'efficacité (TreeSHAP exact)104 assert sh["base"] == pytest.approx(13.0732, abs=0.0005) and sh["base"] == pytest.approx(res["y"].mean(), abs=0.001)105 assert math.exp(sh["base"]) == pytest.approx(476_010, rel=0.001)106 imp = sh["importance"]107 assert list(imp.index[:3]) == ["superficie", "age", "mois"] and imp["superficie"] == pytest.approx(0.111, abs=0.002) and imp["age"] == pytest.approx(0.0432, abs=0.002)108 assert imp[[f"sect_{s}" for s in cas1.SECTEURS]].max() < 0.002 # secteurs absorbés par lat/lon/distance109 s = sh["sujet"]110 assert s["mois"] == pytest.approx(0.0986, abs=0.002) and s["superficie"] == pytest.approx(0.0871, abs=0.002) and s["age"] == pytest.approx(0.0322, abs=0.002)111 assert s["terrain"] == pytest.approx(-0.0127, abs=0.002) and s.sum() + sh["base"] == pytest.approx(res["sujet_pred"].loc["Gradient boosting", "ln_pred"], abs=1e-9)112 # symétrie/efficacité : la somme des |φ| moyens n'excède pas la dispersion de y113 assert imp.sum() < np.std(res["y"]) * 2114 # importance par permutation : même tête de classement115 p = res["permutation"]["Gradient boosting"]116 assert p.idxmax() == "superficie" and p["superficie"] == pytest.approx(0.0348, abs=0.003) and p["age"] == pytest.approx(0.0061, abs=0.0015)117 assert res["permutation"]["Gradient boosting (EQM de base)"] == pytest.approx(0.00867, abs=0.0002)118119def test_sujet_et_pdp(res):120 s = res["sujet_pred"]121 assert s.loc["Gradient boosting", "prediction"] == pytest.approx(605_825, rel=0.003) and s.loc["XGBoost", "prediction"] == pytest.approx(613_976, rel=0.003)122 assert s.loc["Forêt aléatoire", "prediction"] == pytest.approx(586_818, rel=0.003) and s.loc["Arbre (profondeur 3)", "prediction"] == pytest.approx(549_573, rel=0.003)123 assert s.loc["Hédonique (référence)", "prediction"] == pytest.approx(638_011, abs=50)124 # tous les modèles d'arbres prédisent le sujet sous l'hédonique (compression vers la moyenne)125 assert (s["prediction"].drop("Hédonique (référence)") < s.loc["Hédonique (référence)", "prediction"]).all()126 assert s.loc["Gradient boosting", "ln_pred"] - s.loc["Gradient boosting", "ln_pred"] == 0 and math.exp(s.loc["Gradient boosting", "ln_pred"]) == pytest.approx(604_595, rel=0.003)127 pdp = res["pdp"]128 # la PDP de la superficie du boosting suit la loi de génération : corrélation > 0,99 et écart maximal < 6 points (aux extrémités du support)129 for nom in ("Gradient boosting", "Hédonique (référence)"):130 a = pdp["superficie"][nom] - pdp["superficie"][nom].mean(); b = pdp["superficie"]["vraie"] - pdp["superficie"]["vraie"].mean()131 assert np.corrcoef(a, b)[0, 1] > 0.99 and np.abs(a - b).max() < 0.06, nom132 assert np.abs((a - b)[(pdp["superficie"]["grille"] >= 100) & (pdp["superficie"]["grille"] <= 220)]).max() < 0.04, nom133 # scénario : la PDP de l'âge du boosting reproduit le mur (creux entre 25 et 45 ans) que le polynôme hédonique ne peut pas suivre134 g = pdp["age_scenario"]["grille"]; gb = pdp["age_scenario"]["Gradient boosting"]; hd = pdp["age_scenario"]["Hédonique (référence)"]; vr = pdp["age_scenario"]["vraie"]135 m = (g >= 25) & (g <= 45)136 assert np.abs((gb - gb.mean()) - (vr - vr.mean())).max() < np.abs((hd - hd.mean()) - (vr - vr.mean())).max()137 assert np.mean(gb[m]) < np.mean(gb[(g > 46) & (g < 60)])138139def test_courbes_et_sensibilite(res):140 c = res["courbes"]141 assert c[4]["optimum"] < c[2]["optimum"] <= c[1]["optimum"] # plus l'arbre est profond, plus tôt il surapprend142 assert c[4]["entrainement"][-1] < 0.02 and c[4]["validation"][-1] > c[4]["rmse_min"] # erreur d'entraînement quasi nulle, validation remonte143 assert c[1]["rmse_min"] == pytest.approx(0.0887, abs=0.001) and c[2]["rmse_min"] == pytest.approx(0.0923, abs=0.001) and c[4]["rmse_min"] == pytest.approx(0.0966, abs=0.0015)144 assert c[2]["optimum"] == pytest.approx(730, abs=60) and c[4]["optimum"] == pytest.approx(367, abs=60)145 s = res["sensibilite"].set_index(["modele", "profondeur", "arbres", "nu", "feuille_min", "sous_ech"])146 g = s.loc["Gradient boosting"]147 assert g.loc[(1, 1200, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0887, abs=0.001) # souches additives : le meilleur boosting148 assert g.loc[(2, 1200, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0931, abs=0.001)149 assert g.loc[(4, 1200, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0984, abs=0.0015) and g.loc[(2, 1200, 0.10, 5, 0.8), "rmse_log"] == pytest.approx(0.0988, abs=0.0015)150 assert g.loc[(2, 300, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0977, abs=0.0015) and g.loc[(2, 3000, 0.03, 5, 0.8), "rmse_log"] == pytest.approx(0.0962, abs=0.0015)151 f = s.loc["Forêt aléatoire"]152 assert f["rmse_log"].between(0.105, 0.117).all() and f.loc[("max", 100, slice(None), 2, "bootstrap"), "rmse_log"].iloc[0] == pytest.approx(0.1065, abs=0.001)153154def test_solutions_exercices(res):155 e = res["exercices"]156 # 37.1 : meilleure division des huit ventes157 b = e["1"]["best"]; assert b["seuil"] == 113.5 and b["k"] == 2 and b["gain"] == pytest.approx(0.338, abs=0.001) and e["1"]["sse0"] == pytest.approx(0.462, abs=0.001)158 assert b["c_gauche"] == pytest.approx(12.611, abs=0.001) and b["c_droite"] == pytest.approx(13.0857, abs=0.001)159 assert e["1"]["S"] == [70.0, 99.0, 128.0, 156.0, 160.0, 170.0, 221.0, 241.0]160 # 37.2 : variance d'une moyenne d'arbres corrélés161 assert e["2"]["B1"] == pytest.approx(0.0144) and e["2"]["B100"] == pytest.approx(0.004421, abs=1e-6) and e["2"]["limite"] == pytest.approx(0.00432) and e["2"]["ecart_type_limite"] == pytest.approx(0.0657, abs=0.0001)162 # 37.3 : boosting à la main163 assert e["3"]["F"][-1] == pytest.approx(12.8542, abs=1e-4) and e["3"]["residus"][-1] == pytest.approx(0.1458, abs=1e-4) and e["3"]["m_1pct"] == pytest.approx(43.7, abs=0.05)164 # 37.4 : Shapley à trois joueurs165 phi = e["4"]["phi"]; assert phi["S"] == pytest.approx(70.833, abs=0.001) and phi["Q"] == pytest.approx(35.833, abs=0.001) and phi["L"] == pytest.approx(23.333, abs=0.001) and e["4"]["somme"] == pytest.approx(130.0)166 # 37.5 : dix prédictions d'un AVM167 r = e["5"]; assert r["rmse_log"] == pytest.approx(0.0503, abs=0.0001) and r["mdape"] == pytest.approx(4.51, abs=0.01) and r["COD"] == pytest.approx(4.42, abs=0.01)168 assert r["PRD"] == pytest.approx(1.006, abs=0.001) and r["PRB"] == pytest.approx(-0.034, abs=0.001) and r["ratio_median"] == pytest.approx(0.971, abs=0.001) and r["mape"] == pytest.approx(4.82, abs=0.01)169 # 37.6 et 37.7 : chiffres cohérents avec les tableaux170 assert e["6"]["Hédonique (référence)"]["secteur"] == pytest.approx(10.44, abs=0.1) and e["7"]["Hédonique respécifié"] == pytest.approx(6.85, abs=0.1)171