diff --git a/MyIA.AI.Notebooks/ML/DataScienceWithAgents/02-ML-Cours/2.7c-SVM-SOTA-Comparison.ipynb b/MyIA.AI.Notebooks/ML/DataScienceWithAgents/02-ML-Cours/2.7c-SVM-SOTA-Comparison.ipynb new file mode 100644 index 0000000000..74b01ce763 --- /dev/null +++ b/MyIA.AI.Notebooks/ML/DataScienceWithAgents/02-ML-Cours/2.7c-SVM-SOTA-Comparison.ipynb @@ -0,0 +1,901 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c00", + "metadata": { + "papermill": { + "duration": 0.002014, + "end_time": "2026-09-15T04:36:31.710775", + "exception": false, + "start_time": "2026-09-15T04:36:31.708761", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "# 2.7c — SVM SOTA : LIBSVM sous le capot de `sklearn.svm.SVC`\n", + "\n", + "**Navigation** : [<< 2.7b-SMO-From-Scratch](2.7b-SMO-From-Scratch.ipynb) | [Index](README.md) | [2.8-Theorie-PAC >>](2.8-Theorie-PAC.ipynb)\n", + "\n", + "**Kernel** : Python 3 · **Durée estimée** : ~30 min\n", + "\n", + "## Introduction\n", + "\n", + "> **Concept-phare** : [2.7b](2.7b-SMO-From-Scratch.ipynb) a écrit la boucle SMO à la main (~150 lignes, 21 560 itérations, 1,19 s) et l'a validée contre `sklearn.svm.SVC`. Ce notebook inverse la perspective : le solveur industriel devient le sujet. `SVC` est l'enveloppe Python de **LIBSVM** (Chang & Lin, 2011) — du C++ compilé, un working set au second ordre (Fan, Chen & Lin, 2005), du shrinking et un cache de noyau. On mesure ce que ce packaging achète : exactitude, temps, vecteurs supports — puis on ouvre **le dual que SVC ne rend pas** (`dual_coef_`), on chiffre le **gap de dualité et les violations KKT côté LIBSVM** avec la même machinerie que 2.7b, et on actionne les molettes que la boîte noire expose (`tol`).\n", + "\n", + "**Position dans la série** : bloc B.5 de l'EPIC optimisation convexe — le **pendant SOTA** de 2.7b (bloc A.1). Les blocs B.6 (Lasso-SOTA) et B.7 (CVXPY) sont déjà couverts par [2.11b — Proximal Operators](2.11b-Proximal-Operators-From-Scratch.ipynb), qui confronte ISTA/FISTA à `sklearn.linear_model` et à `cvxpy` sur le même problème Lasso — ce notebook ne les double pas.\n", + "\n", + "### Objectifs d'apprentissage\n", + "\n", + "1. **Mesurer** le geste SOTA honnêtement : protocole de temps répété (un fit LIBSVM sur 140 points dure moins qu'une milliseconde — un chrono naïf affiche 0.00), exactitude et vecteurs supports sur le **même dataset que 2.7b**.\n", + "2. **Reconstruire le dual** que `SVC` ne retourne pas : `alpha` complet depuis `dual_coef_` + `support_`, et vérifier la contrainte d'égalité `Σ α_i y_i = 0`.\n", + "3. **Certifier l'optimum côté LIBSVM** : objectif dual, objectif primal reconstruit, gap de dualité, violations KKT résiduelles — les mêmes instruments que 2.7b, pointés sur l'autre solveur.\n", + "4. **Actionner les molettes de production** : sweep de `tol`, et en exercice, `GridSearchCV` sur `C × gamma`.\n", + "\n", + "### Prérequis\n", + "\n", + "- [2.7b — SMO from scratch](2.7b-SMO-From-Scratch.ipynb) : le dual soft-margin, la condition KKT, le gap de dualité (ce notebook réutilise ces instruments sans les redériver).\n", + "- [2.7 — Modèles non paramétriques](2.7-Modeles-Non-Parametriques.ipynb) : SVM, kernel trick, vecteurs supports.\n", + "\n", + "NumPy et scikit-learn suffisent ; le noyau RBF vient de `sklearn.metrics.pairwise` (le côté SOTA jusqu'au bout).\n", + "\n", + "> **Référence.** Chang, C.-C. & Lin, C.-J. (2011), *LIBSVM: a library for support vector machines*, ACM TIST 2(27). Fan, R.-E., Chen, P.-H. & Lin, C.-J. (2005), *Working set selection using second order information for training support vector machines*, JMLR 6." + ] + }, + { + "cell_type": "markdown", + "id": "c01", + "metadata": { + "papermill": { + "duration": 0.001999, + "end_time": "2026-09-15T04:36:31.715774", + "exception": false, + "start_time": "2026-09-15T04:36:31.713775", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Vérification de l'environnement" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c02", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:31.721307Z", + "iopub.status.busy": "2026-09-15T04:36:31.721307Z", + "iopub.status.idle": "2026-09-15T04:36:32.743333Z", + "shell.execute_reply": "2026-09-15T04:36:32.743333Z" + }, + "papermill": { + "duration": 1.026062, + "end_time": "2026-09-15T04:36:32.744347", + "exception": false, + "start_time": "2026-09-15T04:36:31.718285", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "numpy=2.2.6 scikit-learn=1.8.0\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "import numpy as np\n", + "import sklearn\n", + "from sklearn.datasets import make_moons\n", + "from sklearn.metrics import accuracy_score, pairwise\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.svm import SVC\n", + "\n", + "print(f\"numpy={np.__version__} scikit-learn={sklearn.__version__}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c03", + "metadata": { + "papermill": { + "duration": 0.002509, + "end_time": "2026-09-15T04:36:32.748856", + "exception": false, + "start_time": "2026-09-15T04:36:32.746347", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du résultat : l'environnement d'exécution\n", + "\n", + "Tout tourne sur CPU en quelques secondes. La comparaison avec 2.7b n'a de sens que sur le même dataset, la même graine et les mêmes hyperparamètres — c'est la section suivante." + ] + }, + { + "cell_type": "markdown", + "id": "c04", + "metadata": { + "papermill": { + "duration": 0.003001, + "end_time": "2026-09-15T04:36:32.753862", + "exception": false, + "start_time": "2026-09-15T04:36:32.750861", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 1. Le même dataset que 2.7b, à la graine près\n", + "\n", + "Le protocole de 2.7b section 7 est recopié **exactement** : `make_moons(200, noise=0.25, random_state=42)`, labels en {-1, +1}, split stratifié 70/30 (`random_state=42`), `C = 1.0`, `gamma = 2.0`, `tol = 1e-3`. La seule variable expérimentale est le solveur : LIBSVM ici, la SMO de Platt maison dans 2.7b." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c05", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:32.759109Z", + "iopub.status.busy": "2026-09-15T04:36:32.759109Z", + "iopub.status.idle": "2026-09-15T04:36:32.763861Z", + "shell.execute_reply": "2026-09-15T04:36:32.763861Z" + }, + "papermill": { + "duration": 0.008492, + "end_time": "2026-09-15T04:36:32.763861", + "exception": false, + "start_time": "2026-09-15T04:36:32.755369", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "train : 140 points test : 60 points (2.7b : identique)\n", + "C=1.0 gamma=2.0 tol=1e-03 (2.7b : identique)\n" + ] + } + ], + "source": [ + "X, y_pm = make_moons(n_samples=200, noise=0.25, random_state=42)\n", + "y = np.where(y_pm == 0, -1, 1).astype(float)\n", + "X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.30, random_state=42, stratify=y)\n", + "\n", + "C, GAMMA, TOL = 1.0, 2.0, 1e-3\n", + "\n", + "print(f\"train : {len(y_tr)} points test : {len(y_te)} points (2.7b : identique)\")\n", + "print(f\"C={C} gamma={GAMMA} tol={TOL:.0e} (2.7b : identique)\")" + ] + }, + { + "cell_type": "markdown", + "id": "c06", + "metadata": { + "papermill": { + "duration": 0.003072, + "end_time": "2026-09-15T04:36:32.770486", + "exception": false, + "start_time": "2026-09-15T04:36:32.767414", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du résultat : le terrain est posé\n", + "\n", + "Mêmes points, même split, mêmes hyperparamètres qu'en 2.7b : à la fin du notebook, toute différence de résultat ne pourra être attribuée qu'au solveur." + ] + }, + { + "cell_type": "markdown", + "id": "c07", + "metadata": { + "papermill": { + "duration": 0.001983, + "end_time": "2026-09-15T04:36:32.774492", + "exception": false, + "start_time": "2026-09-15T04:36:32.772509", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 2. Le geste SOTA mesuré honnêtement\n", + "\n", + "Un fit LIBSVM sur 140 points dure une fraction de milliseconde : un `perf_counter` autour d'un appel unique affiche `0.00 s` (c'est ce que montre la sortie de 2.7b section 7). Pour comparer des temps de cet ordre, on répète le fit **à froid** (nouvel objet `SVC` à chaque itération, pas de cache d'objets Python entre les mesures) et on rapporte la moyenne." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c08", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:32.780491Z", + "iopub.status.busy": "2026-09-15T04:36:32.780491Z", + "iopub.status.idle": "2026-09-15T04:36:32.809604Z", + "shell.execute_reply": "2026-09-15T04:36:32.809604Z" + }, + "papermill": { + "duration": 0.033118, + "end_time": "2026-09-15T04:36:32.810610", + "exception": false, + "start_time": "2026-09-15T04:36:32.777492", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sklearn SVC (LIBSVM) : accuracy test = 0.967, |SV| = 48, temps moyen = 0.47 ms (moyenne de 50 fits a froid)\n", + "2.7b SMO from scratch (commite) : accuracy test = 0.967, |SV| = 48, temps = 1.19 s, n_iter = 21560\n" + ] + } + ], + "source": [ + "clf = SVC(C=C, kernel=\"rbf\", gamma=GAMMA, tol=TOL, max_iter=20000)\n", + "clf.fit(X_tr, y_tr)\n", + "\n", + "acc_skl = accuracy_score(y_te, clf.predict(X_te))\n", + "n_sv = int(clf.n_support_.sum())\n", + "\n", + "N_REPETS = 50\n", + "t0 = time.perf_counter()\n", + "for _ in range(N_REPETS):\n", + " SVC(C=C, kernel=\"rbf\", gamma=GAMMA, tol=TOL, max_iter=20000).fit(X_tr, y_tr)\n", + "t_skl = (time.perf_counter() - t0) / N_REPETS\n", + "\n", + "print(f\"sklearn SVC (LIBSVM) : accuracy test = {acc_skl:.3f}, |SV| = {n_sv}, \"\n", + " f\"temps moyen = {t_skl * 1e3:.2f} ms (moyenne de {N_REPETS} fits a froid)\")\n", + "print(\"2.7b SMO from scratch (commite) : accuracy test = 0.967, |SV| = 48, temps = 1.19 s, n_iter = 21560\")" + ] + }, + { + "cell_type": "markdown", + "id": "c09", + "metadata": { + "papermill": { + "duration": 0.002514, + "end_time": "2026-09-15T04:36:32.815640", + "exception": false, + "start_time": "2026-09-15T04:36:32.813126", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du résultat : trois ordres de grandeur\n", + "\n", + "- **Exactitude et supports identiques** aux valeurs committées de 2.7b (0,967 ; 48 supports) — sur ce dataset, les deux solveurs convergent vers le même classifieur. 2.7b avait déjà mesuré un Jaccard des supports de 1,000 et un écart de score de décision max de 0,005 : la différence entre solveurs est un epsilon d'optimisation, pas une différence de modèle.\n", + "- **Le temps** : le fit LIBSVM moyen se compte en fraction de milliseconde contre 1,19 s pour la boucle Python — trois ordres de grandeur. Deux causes se superposent : le C++ compilé (chaque sous-problème 2D coûte des nanosecondes au lieu de l'interprétation Python) et le working set au second ordre, qui converge en bien moins de passes que la cascade de Platt.\n", + "- **Le nombre d'itérations de LIBSVM n'est pas exposé** par l'API sklearn : c'est une des choses que la boîte noire cache (et que 2.7b, maison, mesure — 21 560 itérations)." + ] + }, + { + "cell_type": "markdown", + "id": "c10", + "metadata": { + "papermill": { + "duration": 0.002083, + "end_time": "2026-09-15T04:36:32.818739", + "exception": false, + "start_time": "2026-09-15T04:36:32.816656", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 3. Reconstruire le dual que `SVC` ne rend pas\n", + "\n", + "`SVC` n'expose pas le vecteur `alpha` complet — il rend `dual_coef_` (les `y_i · α_i` **des seuls supports**, concaténés) et `support_` (leurs indices dans le train). Le dual complet se reconstruit en deux lignes : `alpha` nul hors supports, `|dual_coef_|` sur les supports. Avec lui, tous les instruments de 2.7b section 10 se pointent sur LIBSVM :\n", + "\n", + "- `||w||²` reconstruit depuis le dual : `Σ_ij (y_i α_i)(y_j α_j) K(x_i, x_j)` ;\n", + "- objectif dual `D(α) = Σ α_i − ½ ||w||²` ;\n", + "- objectif primal `P = ½ ||w||² + C Σ ξ_i`, les `ξ` lus sur `decision_function` ;\n", + "- **gap de dualité** `P − D` et **violations KKT** résiduelles." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c11", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:32.825950Z", + "iopub.status.busy": "2026-09-15T04:36:32.824950Z", + "iopub.status.idle": "2026-09-15T04:36:32.832571Z", + "shell.execute_reply": "2026-09-15T04:36:32.832060Z" + }, + "papermill": { + "duration": 0.010632, + "end_time": "2026-09-15T04:36:32.832571", + "exception": false, + "start_time": "2026-09-15T04:36:32.821939", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "alpha reconstruit : 48 non nuls (n_support_ = 48) somme = 40.1958\n", + "contrainte d'egalite sum(alpha_i * y_i) = 2.22e-16\n", + "||w||^2 depuis le dual = 19.5995\n", + "objectif dual D(alpha) = 30.3961\n", + "objectif primal P(w, b, xi) = 30.3981\n", + "gap de dualite P - D = 1.99e-03 (>= 0 : dualite faible)\n", + "violation KKT residuelle sum V_i = 4.12e-03\n", + " pire violation ponctuelle = 5.22e-04\n", + "repartition des alpha : =0 -> 92, 0 16, =C -> 32\n", + "2.7b SMO (commite) : D = 30.3960, gap = 3.3e-03, sum V = 6.3e-03\n" + ] + } + ], + "source": [ + "# --- reconstruction du dual complet --------------------------------------\n", + "alpha = np.zeros(len(y_tr))\n", + "alpha[clf.support_] = np.abs(clf.dual_coef_[0])\n", + "print(f\"alpha reconstruit : {int((alpha > 1e-12).sum())} non nuls \"\n", + " f\"(n_support_ = {n_sv}) somme = {alpha.sum():.4f}\")\n", + "print(f\"contrainte d'egalite sum(alpha_i * y_i) = {float((alpha * y_tr).sum()):.2e}\")\n", + "\n", + "# --- objectifs primal et dual sur la MEME solution -------------------------\n", + "ay = clf.dual_coef_[0]\n", + "K_ss = pairwise.rbf_kernel(clf.support_vectors_, clf.support_vectors_, gamma=GAMMA)\n", + "w2 = float(ay @ K_ss @ ay)\n", + "D_libsvm = float(alpha.sum() - 0.5 * w2)\n", + "\n", + "f_tr = clf.decision_function(X_tr)\n", + "xi = np.maximum(0.0, 1.0 - y_tr * f_tr)\n", + "P_libsvm = float(0.5 * w2 + C * xi.sum())\n", + "gap = P_libsvm - D_libsvm\n", + "\n", + "# --- violations KKT residuelles (meme forme que 2.7b section 10) -----------\n", + "viol_low = np.where(alpha < C - 1e-12, np.maximum(0.0, 1.0 - y_tr * f_tr), 0.0)\n", + "viol_high = np.where(alpha > 1e-12, np.maximum(0.0, y_tr * f_tr - 1.0), 0.0)\n", + "V = float(viol_low.sum() + viol_high.sum())\n", + "\n", + "print(f\"||w||^2 depuis le dual = {w2:.4f}\")\n", + "print(f\"objectif dual D(alpha) = {D_libsvm:.4f}\")\n", + "print(f\"objectif primal P(w, b, xi) = {P_libsvm:.4f}\")\n", + "print(f\"gap de dualite P - D = {gap:.2e} (>= 0 : dualite faible)\")\n", + "print(f\"violation KKT residuelle sum V_i = {V:.2e}\")\n", + "print(f\" pire violation ponctuelle = {max(viol_low.max(), viol_high.max()):.2e}\")\n", + "print(f\"repartition des alpha : =0 -> {(alpha <= 1e-6).sum()}, \"\n", + " f\"0 {((alpha > 1e-6) & (alpha < C - 1e-6)).sum()}, \"\n", + " f\"=C -> {(alpha >= C - 1e-6).sum()}\")\n", + "print(\"2.7b SMO (commite) : D = 30.3960, gap = 3.3e-03, sum V = 6.3e-03\")" + ] + }, + { + "cell_type": "markdown", + "id": "c12", + "metadata": { + "papermill": { + "duration": 0.001506, + "end_time": "2026-09-15T04:36:32.837084", + "exception": false, + "start_time": "2026-09-15T04:36:32.835578", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du résultat : LIBSVM s'arrête aussi à `tol` près\n", + "\n", + "- **La contrainte d'égalité est exacte** (`Σ α_i y_i ≈ 10⁻¹⁶`) : la reconstruction depuis `dual_coef_` est fidèle, et c'est l'exercice 2 qui la fait vérifier à fond.\n", + "- **Le gap de dualité côté LIBSVM est plus serré** que celui de la SMO maison (2.7b : `3.3 × 10⁻³`) — même ordre de grandeur, epsilon plus petit. Aucun des deux solveurs n'atteint l'optimum exact : **les deux s'arrêtent à `tol = 10⁻³` près**, chacun avec son critère. La complémentarité KKT (points hors marge à `α = 0`, points à `α = C` hors ou sur la marge, points dans la marge à `0 < α < C`) se lit sur la répartition des `alpha` comme en 2.7b.\n", + "- **Le dual est le même** : 2.7b avait déjà évalué le `D(α)` de LIBSVM en contre-épreuve (écart `4 × 10⁻⁵` avec sa propre solution). Ici on le mesure depuis l'intérieur : c'est la même mathématique, conditionnée par un arrêt approché assumé." + ] + }, + { + "cell_type": "markdown", + "id": "c13", + "metadata": { + "papermill": { + "duration": 0.00251, + "end_time": "2026-09-15T04:36:32.842293", + "exception": false, + "start_time": "2026-09-15T04:36:32.839783", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 4. Le tableau comparatif : from scratch (2.7b) vs SOTA (LIBSVM)\n", + "\n", + "Les colonnes 2.7b citent les valeurs **committées** de son exécution (même machine, même dataset) ; les colonnes LIBSVM sont mesurées ci-dessus. Les lignes de code comptent le code spécifique au solveur : `rbf_kernel` + sous-problème 2D + cascade de Platt + `smo_fit` pour 2.7b, contre l'appel `SVC(...).fit(...)` ici." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c14", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:32.847805Z", + "iopub.status.busy": "2026-09-15T04:36:32.847805Z", + "iopub.status.idle": "2026-09-15T04:36:32.851823Z", + "shell.execute_reply": "2026-09-15T04:36:32.851318Z" + }, + "papermill": { + "duration": 0.00753, + "end_time": "2026-09-15T04:36:32.851823", + "exception": false, + "start_time": "2026-09-15T04:36:32.844293", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================================================================================\n", + "solveur D(alpha) gap P-D accuracy |SV| temps n_iter LOC\n", + "------------------------------------------------------------------------------------------------\n", + "LIBSVM via SVC (mesure) 30.3961 2.0e-03 0.967 48 0.47ms n/d 2\n", + "SMO Platt maison (2.7b) 30.3960 3.3e-03 0.967 48 1.19s 21560 150\n", + "================================================================================================\n", + "n_iter LIBSVM : non expose par l'API sklearn (boite noire) -- 2.7b maison le mesure.\n" + ] + } + ], + "source": [ + "SMO_D, SMO_GAP, SMO_ACC, SMO_SV, SMO_T, SMO_ITER, SMO_LOC = 30.3960, 3.3e-3, 0.967, 48, 1.19, 21560, 150\n", + "\n", + "print(\"=\" * 96)\n", + "print(f\"{'solveur':<26}{'D(alpha)':>10}{'gap P-D':>10}{'accuracy':>10}\"\n", + " f\"{'|SV|':>6}{'temps':>10}{'n_iter':>9}{'LOC':>6}\")\n", + "print(\"-\" * 96)\n", + "print(f\"{'LIBSVM via SVC (mesure)':<26}{D_libsvm:>10.4f}{gap:>10.1e}{acc_skl:>10.3f}\"\n", + " f\"{n_sv:>6}{t_skl * 1e3:>8.2f}ms{'n/d':>9}{2:>6}\")\n", + "print(f\"{'SMO Platt maison (2.7b)':<26}{SMO_D:>10.4f}{SMO_GAP:>10.1e}{SMO_ACC:>10.3f}\"\n", + " f\"{SMO_SV:>6}{SMO_T:>8.2f}s{SMO_ITER:>9}{SMO_LOC:>6}\")\n", + "print(\"=\" * 96)\n", + "print(\"n_iter LIBSVM : non expose par l'API sklearn (boite noire) -- 2.7b maison le mesure.\")" + ] + }, + { + "cell_type": "markdown", + "id": "c15", + "metadata": { + "papermill": { + "duration": 0.002, + "end_time": "2026-09-15T04:36:32.856830", + "exception": false, + "start_time": "2026-09-15T04:36:32.854830", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du résultat : pourquoi le from scratch, quand le SOTA\n", + "\n", + "- **Même optimum, mêmes supports, même classifieur** : sur ce problème, le SOTA n'achète pas de la qualité de solution, il achète du **temps** (trois ordres de grandeur) et de l'**écosystème** (`predict`/`decision_function`/`GridSearchCV`/`pipeline`).\n", + "- **Ce que la boîte noire cache, le from scratch le montre** : le nombre d'itérations, la trajectoire des `alpha`, le mécanisme du working set — 2.7b rend tout cela visible parce que tout y est écrit à la main. Diagnostiquer un LIBSVM qui ne converge pas (`max_iter` atteint, warnings de shrinking) exige de savoir ce qui vit dessous.\n", + "- La règle pratique de l'EPIC : **from scratch pour comprendre** (2.7b), **SOTA pour produire** (ce notebook) — et les deux se certifient mutuellement, puisque chacun chiffre le dual de l'autre." + ] + }, + { + "cell_type": "markdown", + "id": "c16", + "metadata": { + "papermill": { + "duration": 0.003011, + "end_time": "2026-09-15T04:36:32.862347", + "exception": false, + "start_time": "2026-09-15T04:36:32.859336", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 5. Les molettes du SOTA : la tolérance d'arrêt\n", + "\n", + "`tol` est le contrat de convergence que LIBSVM signe avec vous : le solveur s'arrête dès que la violation KKT la plus forte passe sous ce seuil. Plus il est serré, plus le dual monte (vers le même optimum), plus le temps monte — légèrement : sur 140 points, tout reste sub-milliseconde." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c17", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:32.868348Z", + "iopub.status.busy": "2026-09-15T04:36:32.868348Z", + "iopub.status.idle": "2026-09-15T04:36:32.972188Z", + "shell.execute_reply": "2026-09-15T04:36:32.972188Z" + }, + "papermill": { + "duration": 0.108848, + "end_time": "2026-09-15T04:36:32.973195", + "exception": false, + "start_time": "2026-09-15T04:36:32.864347", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " tol temps moyen accuracy |SV| D(alpha) gap P-D\n", + " 1e-02 0.47ms 0.967 49 30.3953 1.6e-02\n", + " 1e-03 0.44ms 0.967 48 30.3961 2.0e-03\n", + " 1e-04 0.46ms 0.967 48 30.3961 1.7e-04\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 1e-05 0.49ms 0.967 48 30.3961 2.4e-05\n" + ] + } + ], + "source": [ + "print(f\"{'tol':>8}{'temps moyen':>14}{'accuracy':>10}{'|SV|':>6}{'D(alpha)':>10}{'gap P-D':>10}\")\n", + "for tol_i in (1e-2, 1e-3, 1e-4, 1e-5):\n", + " clf_i = SVC(C=C, kernel=\"rbf\", gamma=GAMMA, tol=tol_i, max_iter=200000)\n", + " t0 = time.perf_counter()\n", + " for _ in range(N_REPETS):\n", + " SVC(C=C, kernel=\"rbf\", gamma=GAMMA, tol=tol_i, max_iter=200000).fit(X_tr, y_tr)\n", + " t_i = (time.perf_counter() - t0) / N_REPETS\n", + " clf_i.fit(X_tr, y_tr)\n", + " acc_i = accuracy_score(y_te, clf_i.predict(X_te))\n", + "\n", + " a_i = np.zeros(len(y_tr))\n", + " a_i[clf_i.support_] = np.abs(clf_i.dual_coef_[0])\n", + " ay_i = clf_i.dual_coef_[0]\n", + " K_i = pairwise.rbf_kernel(clf_i.support_vectors_, clf_i.support_vectors_, gamma=GAMMA)\n", + " w2_i = float(ay_i @ K_i @ ay_i)\n", + " D_i = float(a_i.sum() - 0.5 * w2_i)\n", + " f_i = clf_i.decision_function(X_tr)\n", + " P_i = float(0.5 * w2_i + C * np.maximum(0.0, 1.0 - y_tr * f_i).sum())\n", + "\n", + " print(f\"{tol_i:>8.0e}{t_i * 1e3:>11.2f}ms{acc_i:>10.3f}\"\n", + " f\"{int(clf_i.n_support_.sum()):>6}{D_i:>10.4f}{P_i - D_i:>10.1e}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c18", + "metadata": { + "papermill": { + "duration": 0.003006, + "end_time": "2026-09-15T04:36:32.978230", + "exception": false, + "start_time": "2026-09-15T04:36:32.975224", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du résultat : la convergence s'achète à la demande\n", + "\n", + "L'objectif dual grimpe doucement quand `tol` se resserre et le gap rétrécit — l'optimum ne change pas de nature, seule la précision du contrat change. Sur ce dataset, l'accuracy est stable sur toute la grille et les supports se stabilisent dès `tol = 10⁻³` (49 supports à `10⁻²`, 48 ensuite) : c'est l'indice qu'un `tol` lâche suffit quand le problème est bien séparé, et qu'un `tol` serré est de l'argent gaspillé — sauf sur les problèmes dégénérés où les supports instables font la différence. Le prix reste ici sub-milliseconde ; c'est sur les gros datasets qu'il se paie réellement." + ] + }, + { + "cell_type": "markdown", + "id": "c19", + "metadata": { + "papermill": { + "duration": 0.002999, + "end_time": "2026-09-15T04:36:32.983738", + "exception": false, + "start_time": "2026-09-15T04:36:32.980739", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 6. Ce que LIBSVM fait que notre SMO ne fait pas\n", + "\n", + "Trois mécanismes séparent LIBSVM de la boucle de Platt de 2.7b — aucun ne change l'optimum, tous changent le chemin :\n", + "\n", + "1. **Working set au second ordre** (Fan, Chen & Lin, 2005) : au lieu de balayer les exemples à la recherche d'une violation KKT (cascade de Platt), LIBSVM choisit la paire qui **maximise le gain prédit du sous-problème 2D** — une sélection informée par la courbure (les `η`), qui réduit le nombre d'itérations d'un ordre de grandeur sur les grands problèmes. C'est exactement le working set alternatif que l'exercice 3 de 2.7b fait explorer.\n", + "2. **Shrinking** : les `α_i` qui n'ont pas bougé depuis longtemps sont provisoirement retirés du problème actif (ils seront presque tous à 0 ou à `C` à l'optimum) — le sous-problème rétrécit pendant la résolution, et est re-vérifié à la fin.\n", + "3. **Cache de noyau** : la matrice de Gram ne tient pas en mémoire sur les gros datasets ; LIBSVM cache les colonnes récemment utilisées et recompute le reste. Notre implémentation matérialise `K` en entier — impossible au-delà de quelques dizaines de milliers de points.\n", + "\n", + "La contrepartie du packaging : `n_iter` invisible, trajectoire des `α` invisible, paramètres internes (`cache_size`, `shrinking`) réduits à des molettes opaques." + ] + }, + { + "cell_type": "markdown", + "id": "c20", + "metadata": { + "papermill": { + "duration": 0.002988, + "end_time": "2026-09-15T04:36:32.988632", + "exception": false, + "start_time": "2026-09-15T04:36:32.985644", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Exercices\n", + "\n", + "Trois exercices progressifs : le premier fait varier `C` sur le solveur SOTA et relie le budget de marge au nombre de supports ; le deuxième vérifie en profondeur la reconstruction du dual de la section 3 ; le troisième est le geste de production complet — la recherche d'hyperparamètres par validation croisée." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c21", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:32.994144Z", + "iopub.status.busy": "2026-09-15T04:36:32.994144Z", + "iopub.status.idle": "2026-09-15T04:36:32.997094Z", + "shell.execute_reply": "2026-09-15T04:36:32.997094Z" + }, + "papermill": { + "duration": 0.00747, + "end_time": "2026-09-15T04:36:32.998108", + "exception": false, + "start_time": "2026-09-15T04:36:32.990638", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice 1 a completer -- sweep_C([0.01, 1, 100]) retourne None\n" + ] + } + ], + "source": [ + "# Exercice 1 : l'effet de C sur le nombre de vecteurs supports, cote LIBSVM\n", + "# TODO etudiant : completez sweep_C(valeurs) qui, pour chaque C de valeurs,\n", + "# entraine un SVC(C=C, kernel='rbf', gamma=2.0, tol=1e-3) sur (X_tr, y_tr),\n", + "# et retourne la liste des couples (n_supports, accuracy_test). Faites varier\n", + "# C dans [0.01, 0.1, 1, 10, 100] et expliquez la MONOTONIE du nombre de\n", + "# supports : pourquoi un C petit fabrique beaucoup de supports a la borne C,\n", + "# et un C grand n'en fabrique presque plus ?\n", + "\n", + "\n", + "def sweep_C(valeurs):\n", + " # Indice : clf.n_support_.sum() pour le compte ; accuracy_score(y_te, ...).\n", + " # Etape 1 : boucle sur valeurs, fit a froid.\n", + " # Etape 2 : collecter (n_supports, accuracy) de chaque fit.\n", + " result = None # TODO etudiant\n", + " return result\n", + "\n", + "\n", + "print(\"Exercice 1 a completer -- sweep_C([0.01, 1, 100]) retourne\", sweep_C([0.01, 1, 100]))" + ] + }, + { + "cell_type": "markdown", + "id": "c22", + "metadata": { + "papermill": { + "duration": 0.002507, + "end_time": "2026-09-15T04:36:33.002614", + "exception": false, + "start_time": "2026-09-15T04:36:33.000107", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Exercice 2 : certifier la reconstruction du dual\n", + "\n", + "La section 3 reconstruit `alpha` depuis `dual_coef_` et constate `Σ α_i y_i ≈ 10⁻¹⁶`. Une vérification plus forte compare les **scores de décision** recalculés depuis le dual reconstruit à ceux de `clf.decision_function`." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c23", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:33.009299Z", + "iopub.status.busy": "2026-09-15T04:36:33.009299Z", + "iopub.status.idle": "2026-09-15T04:36:33.012974Z", + "shell.execute_reply": "2026-09-15T04:36:33.012974Z" + }, + "papermill": { + "duration": 0.007678, + "end_time": "2026-09-15T04:36:33.012974", + "exception": false, + "start_time": "2026-09-15T04:36:33.005296", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice 2 a completer -- ecart_reconstruction() retourne None\n" + ] + } + ], + "source": [ + "# Exercice 2 : le dual reconstruit reproduit-il decision_function ?\n", + "# TODO etudiant : ecrivez ecart_reconstruction(clf, X) qui recalcule le score\n", + "# de decision f(x) = sum_i alpha_i y_i K(x_i, x) + b sur CHAQUE ligne de X\n", + "# depuis (alpha reconstruit, y_tr, X_tr, clf.intercept_), en utilisant\n", + "# pairwise.rbf_kernel(X, X_tr, gamma), et retourne l'ecart max\n", + "# |f_recalcule - clf.decision_function(X)|. Resultat attendu : ~1e-15.\n", + "\n", + "\n", + "def ecart_reconstruction(clf, X):\n", + " # Indice : les alpha non supports valent 0 -- la somme se reduit aux\n", + " # supports, mais LAISSER la somme complete sur X_tr est plus simple et\n", + " # numeriquement equivalent. Le biais est clf.intercept_[0].\n", + " # Etape 1 : reconstruire alpha (section 3).\n", + " # Etape 2 : f = (alpha * y_tr) @ rbf_kernel(X, X_tr).T + b.\n", + " # Etape 3 : ecart max avec clf.decision_function(X).\n", + " result = None # TODO etudiant\n", + " return result\n", + "\n", + "\n", + "print(\"Exercice 2 a completer -- ecart_reconstruction() retourne\",\n", + " ecart_reconstruction(clf, X_te))" + ] + }, + { + "cell_type": "markdown", + "id": "c24", + "metadata": { + "papermill": { + "duration": 0.004067, + "end_time": "2026-09-15T04:36:33.019785", + "exception": false, + "start_time": "2026-09-15T04:36:33.015718", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Exercice 3 : le geste de production — `GridSearchCV` sur `C × gamma`\n", + "\n", + "Le SOTA ne s'arrête pas au solveur : l'écosystème enchaîne la validation croisée sur la grille d'hyperparamètres, avec le scoring de votre choix." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "c25", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-15T04:36:33.026809Z", + "iopub.status.busy": "2026-09-15T04:36:33.026809Z", + "iopub.status.idle": "2026-09-15T04:36:33.029657Z", + "shell.execute_reply": "2026-09-15T04:36:33.029657Z" + }, + "papermill": { + "duration": 0.008371, + "end_time": "2026-09-15T04:36:33.030664", + "exception": false, + "start_time": "2026-09-15T04:36:33.022293", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice 3 a completer -- meilleure_grille() retourne None\n" + ] + } + ], + "source": [ + "# Exercice 3 : grille C x gamma par validation croisee\n", + "# TODO etudiant : avec sklearn.model_selection.GridSearchCV, cherchez la\n", + "# meilleure combinaison C dans [0.1, 1, 10] x gamma dans [0.5, 2, 8] en\n", + "# 5-fold sur (X_tr, y_tr), scoring='accuracy'. Affichez best_params_,\n", + "# best_score_, et comparez l'accuracy test du meilleur modele a celle\n", + "# mesuree ici (0.967) avec C=1, gamma=2. La grille trouve-t-elle mieux ?\n", + "\n", + "\n", + "def meilleure_grille():\n", + " # Indice : GridSearchCV(SVC(kernel='rbf'), {'C': [...], 'gamma': [...]},\n", + " # cv=5).fit(X_tr, y_tr) ; le meilleur estimateur est deja refitte,\n", + " # accessible via .best_estimator_.\n", + " # Etape 1 : poser la grille et fitter.\n", + " # Etape 2 : retourner (best_params, best_score_cv, accuracy_test).\n", + " result = None # TODO etudiant\n", + " return result\n", + "\n", + "\n", + "print(\"Exercice 3 a completer -- meilleure_grille() retourne\", meilleure_grille())" + ] + }, + { + "cell_type": "markdown", + "id": "c26", + "metadata": { + "papermill": { + "duration": 0.003001, + "end_time": "2026-09-15T04:36:33.036665", + "exception": false, + "start_time": "2026-09-15T04:36:33.033664", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Conclusion\n", + "\n", + "Le geste SOTA tient en deux lignes et trois ordres de grandeur de temps gagnés, mais ce notebook a surtout montré que **la boîte noire s'ouvre** : le dual complet se reconstruit depuis `dual_coef_`, le gap de dualité et les violations KKT se mesurent sur LIBSVM exactement comme sur la SMO maison, et la tolérance d'arrêt est un contrat explicite — pas une promesse d'optimalité exacte. Les deux solveurs rendent le même classifieur au `tol` près : c'est la définition opérationnelle d'un problème bien posé.\n", + "\n", + "**Couverture de l'EPIC optimisation convexe** ([#16061](https://github.com/jsboige/CoursIA/issues/16061)) : ce notebook couvre le **bloc B.5** (SVM-SOTA). Les blocs **B.6** (Lasso-SOTA : `sklearn.linear_model` coordinate descent, CV du λ) et **B.7** (CVXPY déclaratif, solvers ECOS/SCS) sont couverts par [2.11b — Proximal Operators from scratch](2.11b-Proximal-Operators-From-Scratch.ipynb), qui confronte ISTA/FISTA aux implémentations scikit-learn et à `cvxpy` sur le même problème Lasso. Les blocs A.1 (SMO) et A.3 (proximal) sont livrés par [2.7b](2.7b-SMO-From-Scratch.ipynb) et 2.11b ; le bloc A.2 (ADMM) reste ouvert.\n", + "\n", + "**Pour aller plus loin** : [2.7b](2.7b-SMO-From-Scratch.ipynb) pour la boucle SMO complète et ses diagnostics visuels ; [2.7](2.7-Modeles-Non-Parametriques.ipynb) pour le cadre SVM/kernel ; [2.11b](2.11b-Proximal-Operators-From-Scratch.ipynb) pour l'autre grande famille de l'optimisation convexe non différentiable.\n", + "\n", + "## Références\n", + "\n", + "1. Chang, C.-C. & Lin, C.-J. (2011). *LIBSVM: a library for support vector machines*. ACM Transactions on Intelligent Systems and Technology, 2(27).\n", + "2. Fan, R.-E., Chen, P.-H. & Lin, C.-J. (2005). *Working set selection using second order information for training support vector machines*. Journal of Machine Learning Research, 6.\n", + "3. Platt, J. C. (1998). *Sequential Minimal Optimization: A Fast Algorithm for Training Support Vector Machines*. MSR-TR-98-14.\n", + "4. Pedregosa, F. et al. (2011). *Scikit-learn: Machine Learning in Python*. JMLR 12." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + }, + "papermill": { + "default_parameters": {}, + "duration": 3.021038, + "end_time": "2026-09-15T04:36:33.374819", + "environment_variables": {}, + "exception": null, + "input_path": "2.7c-SVM-SOTA-Comparison.ipynb", + "output_path": "2.7c-SVM-SOTA-Comparison.ipynb", + "parameters": {}, + "start_time": "2026-09-15T04:36:30.353781", + "version": "2.6.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file