Spaces:
Sleeping
Sleeping
functionNormally commited on
Commit ·
e7db87b
1
Parent(s): c7f3e02
Ajouter MLP baseline, courbes de perte, CV et backbone ImageNet brut
Browse filesSuite aux retours de Marco sur la conception du QCM :
- Tab MLP (baseline) avant le CNN pour montrer que le surapprentissage
persiste quel que soit le choix d'hyperparametres (nombre de couches,
neurones par couche), motivant le passage au CNN
- Courbes de perte train/validation par epoque (MLP et CNN)
- Backbone ResNet18 remplace par les poids ImageNet d'origine, sans
fine-tuning sur le dataset (retire la dependance a HF_BACKBONE_REPO)
- Chaque algorithme classique (SVM, LogReg, k-NN, RF) n'expose plus
qu'un seul hyperparametre ; LDA retire
- Option de validation croisee (CV stratifiee, fallback si classe trop
petite) affichant score CV et score test cote a cote
- app.py +183 -75
- backbone_utils.py +4 -12
- classical_ml_utils.py +25 -7
- config.py +2 -2
- finetune_backbone.py +4 -0
- metrics_utils.py +22 -0
- model.py +29 -0
- train_utils.py +128 -3
app.py
CHANGED
|
@@ -12,6 +12,7 @@ from train_utils import (
|
|
| 12 |
list_saved_models,
|
| 13 |
model_meta_path,
|
| 14 |
train_cnn,
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
# ---------------------------------------------------------------------------
|
|
@@ -35,7 +36,44 @@ def refresh_gallery_callback(split_name, class_name, max_images):
|
|
| 35 |
|
| 36 |
|
| 37 |
# ---------------------------------------------------------------------------
|
| 38 |
-
# Tab 2 —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# ---------------------------------------------------------------------------
|
| 40 |
|
| 41 |
@spaces.GPU(duration=300)
|
|
@@ -68,14 +106,15 @@ def train_cnn_callback(
|
|
| 68 |
result["classification_report"],
|
| 69 |
result["confusion_matrix"],
|
| 70 |
result["confusion_matrix_path"],
|
|
|
|
| 71 |
gr.update(choices=models, value=selected),
|
| 72 |
)
|
| 73 |
except Exception as e:
|
| 74 |
-
return f"Échec de l'entraînement :\n{e}", None, None, None, None, None, gr.update()
|
| 75 |
|
| 76 |
|
| 77 |
# ---------------------------------------------------------------------------
|
| 78 |
-
# Tab
|
| 79 |
# ---------------------------------------------------------------------------
|
| 80 |
|
| 81 |
@spaces.GPU(duration=60)
|
|
@@ -92,16 +131,16 @@ def extract_features_callback():
|
|
| 92 |
|
| 93 |
def on_clf_type_change(clf_type):
|
| 94 |
show = lambda t: gr.update(visible=(clf_type == t))
|
| 95 |
-
return show("SVM"), show("Régression logistique"), show("k-NN"), show("Forêt aléatoire")
|
| 96 |
|
| 97 |
|
| 98 |
def train_classical_callback(
|
| 99 |
clf_type,
|
| 100 |
-
svm_c,
|
| 101 |
-
logreg_c,
|
| 102 |
-
knn_k,
|
| 103 |
-
rf_n_estimators,
|
| 104 |
-
|
| 105 |
model_tag,
|
| 106 |
):
|
| 107 |
try:
|
|
@@ -114,19 +153,18 @@ def train_classical_callback(
|
|
| 114 |
|
| 115 |
params = {}
|
| 116 |
if clf_type == "SVM":
|
| 117 |
-
params = {"C": float(svm_c)
|
| 118 |
elif clf_type == "Régression logistique":
|
| 119 |
-
params = {"C": float(logreg_c)
|
| 120 |
elif clf_type == "k-NN":
|
| 121 |
-
params = {"n_neighbors": int(knn_k)
|
| 122 |
elif clf_type == "Forêt aléatoire":
|
| 123 |
-
|
| 124 |
-
params = {"n_estimators": int(rf_n_estimators), "max_depth": depth}
|
| 125 |
-
elif clf_type == "LDA":
|
| 126 |
-
params = {"solver": lda_solver}
|
| 127 |
|
| 128 |
class_names = get_class_names()
|
| 129 |
-
result = train_classical_model(
|
|
|
|
|
|
|
| 130 |
|
| 131 |
models = list_saved_models()
|
| 132 |
selected = result["model_name"] if result["model_name"] in models else None
|
|
@@ -142,7 +180,7 @@ def train_classical_callback(
|
|
| 142 |
|
| 143 |
|
| 144 |
# ---------------------------------------------------------------------------
|
| 145 |
-
# Tab
|
| 146 |
# ---------------------------------------------------------------------------
|
| 147 |
|
| 148 |
def refresh_models_callback():
|
|
@@ -195,11 +233,11 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 195 |
|
| 196 |
gr.Markdown("# Classification d'images microscopiques de charbons de bois")
|
| 197 |
gr.Markdown(
|
| 198 |
-
"Ce parcours pédagogique suit une progression en
|
| 199 |
-
"**exploration des données**, **
|
| 200 |
"puis **exploitation d'un backbone préentraîné avec des algorithmes classiques**. "
|
| 201 |
-
"L'objectif est de comprendre pourquoi l'apprentissage par
|
| 202 |
-
"surtout quand les données sont rares."
|
| 203 |
)
|
| 204 |
|
| 205 |
with gr.Tabs():
|
|
@@ -240,14 +278,79 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 240 |
image_gallery = gr.Gallery(label="Exemples d'images", columns=4, height=600)
|
| 241 |
|
| 242 |
# ------------------------------------------------------------------ #
|
| 243 |
-
# Tab 2 —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
# ------------------------------------------------------------------ #
|
| 245 |
-
with gr.Tab("
|
| 246 |
gr.Markdown("## Entraîner un réseau convolutif sans connaissances préalables")
|
| 247 |
gr.Markdown(
|
| 248 |
-
"
|
| 249 |
-
"
|
| 250 |
-
"
|
|
|
|
|
|
|
|
|
|
| 251 |
"**Contexte du problème :** notre jeu de données contient 39 espèces, "
|
| 252 |
"avec seulement 8 images par espèce en moyenne. "
|
| 253 |
"C'est extrêmement peu pour apprendre à distinguer 39 classes visuellement similaires.\n\n"
|
|
@@ -305,36 +408,43 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 305 |
cnn_history = gr.JSON(label="Historique époque par époque")
|
| 306 |
cnn_summary = gr.JSON(label="Résumé final")
|
| 307 |
|
|
|
|
|
|
|
|
|
|
| 308 |
gr.Markdown("## Résultats sur le jeu de test")
|
| 309 |
cnn_report = gr.Dataframe(label="Rapport de classification", interactive=False)
|
| 310 |
cnn_cm = gr.Dataframe(label="Matrice de confusion", interactive=False)
|
| 311 |
cnn_cm_img = gr.Image(label="Matrice de confusion — figure", type="filepath")
|
| 312 |
|
| 313 |
# ------------------------------------------------------------------ #
|
| 314 |
-
# Tab
|
| 315 |
# ------------------------------------------------------------------ #
|
| 316 |
-
with gr.Tab("
|
| 317 |
gr.Markdown("## Exploiter les connaissances d'un modèle préentraîné")
|
| 318 |
gr.Markdown(
|
| 319 |
-
"Face aux limites observées avec le CNN de zéro
|
| 320 |
-
"consiste à réutiliser un réseau déjà
|
| 321 |
-
"qu'il a apprises.\n\n"
|
| 322 |
"### Qu'est-ce qu'un backbone ?\n"
|
| 323 |
"Un **backbone** est un réseau convolutif dont on retire la couche de classification finale. "
|
| 324 |
"Il agit comme un extracteur de caractéristiques : pour chaque image en entrée, "
|
| 325 |
"il produit un vecteur de nombres (ici **512 dimensions**) qui encode le contenu visuel "
|
| 326 |
"de l'image de façon compacte et abstraite.\n\n"
|
| 327 |
"### Quel backbone utilisons-nous ici ?\n"
|
| 328 |
-
"Nous utilisons un **ResNet18
|
| 329 |
-
"Ce modèle a
|
| 330 |
-
"
|
| 331 |
-
"
|
|
|
|
| 332 |
"### Pourquoi des algorithmes classiques ensuite ?\n"
|
| 333 |
"Une fois les images transformées en vecteurs de 512 dimensions, "
|
| 334 |
"n'importe quel algorithme de classification classique peut être appliqué. "
|
| 335 |
-
"Ces algorithmes (SVM, régression logistique, k-NN
|
| 336 |
"interprétables, et ne nécessitent pas de GPU. "
|
| 337 |
-
"
|
|
|
|
|
|
|
|
|
|
| 338 |
)
|
| 339 |
|
| 340 |
gr.Markdown("## Étape 1 — Extraction des caractéristiques")
|
|
@@ -358,60 +468,44 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 358 |
with gr.Row():
|
| 359 |
with gr.Column():
|
| 360 |
clf_type = gr.Radio(
|
| 361 |
-
choices=["SVM", "Régression logistique", "k-NN", "Forêt aléatoire"
|
| 362 |
value="SVM",
|
| 363 |
label="Algorithme de classification",
|
| 364 |
)
|
| 365 |
|
| 366 |
with gr.Column(visible=True) as svm_col:
|
| 367 |
-
gr.Markdown("####
|
| 368 |
svm_c = gr.Number(
|
| 369 |
value=1.0, label="C — force de régularisation",
|
| 370 |
info="Une valeur faible regularise davantage (marges plus larges).",
|
| 371 |
)
|
| 372 |
-
svm_kernel = gr.Dropdown(
|
| 373 |
-
choices=["rbf", "linear", "poly"], value="rbf",
|
| 374 |
-
label="Noyau",
|
| 375 |
-
info="RBF est généralement le meilleur point de départ.",
|
| 376 |
-
)
|
| 377 |
-
svm_gamma = gr.Dropdown(
|
| 378 |
-
choices=["scale", "auto"], value="scale", label="Gamma"
|
| 379 |
-
)
|
| 380 |
|
| 381 |
with gr.Column(visible=False) as logreg_col:
|
| 382 |
-
gr.Markdown("####
|
| 383 |
logreg_c = gr.Number(value=1.0, label="C — force de régularisation")
|
| 384 |
-
logreg_max_iter = gr.Number(value=1000, label="Itérations max")
|
| 385 |
|
| 386 |
with gr.Column(visible=False) as knn_col:
|
| 387 |
-
gr.Markdown("####
|
| 388 |
knn_k = gr.Slider(
|
| 389 |
minimum=1, maximum=20, value=5, step=1,
|
| 390 |
label="k — nombre de voisins",
|
| 391 |
info="k=1 mémorise les données, k élevé généralise davantage.",
|
| 392 |
)
|
| 393 |
-
knn_metric = gr.Dropdown(
|
| 394 |
-
choices=["euclidean", "cosine", "manhattan"],
|
| 395 |
-
value="euclidean", label="Métrique de distance",
|
| 396 |
-
)
|
| 397 |
|
| 398 |
with gr.Column(visible=False) as rf_col:
|
| 399 |
-
gr.Markdown("####
|
| 400 |
rf_n_estimators = gr.Slider(
|
| 401 |
minimum=10, maximum=500, value=100, step=10, label="Nombre d'arbres"
|
| 402 |
)
|
| 403 |
-
rf_max_depth = gr.Number(
|
| 404 |
-
value=0, label="Profondeur max (0 = illimitée)"
|
| 405 |
-
)
|
| 406 |
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
|
| 413 |
ml_tag = gr.Textbox(
|
| 414 |
-
label="Nom du modèle", placeholder="ex.
|
| 415 |
)
|
| 416 |
train_classical_btn = gr.Button("Entraîner le classifieur", variant="primary")
|
| 417 |
|
|
@@ -423,13 +517,13 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 423 |
ml_cm_img = gr.Image(label="Matrice de confusion — figure", type="filepath")
|
| 424 |
|
| 425 |
# ------------------------------------------------------------------ #
|
| 426 |
-
# Tab
|
| 427 |
# ------------------------------------------------------------------ #
|
| 428 |
-
with gr.Tab("
|
| 429 |
gr.Markdown("## Comparer et évaluer les modèles")
|
| 430 |
gr.Markdown(
|
| 431 |
"Tous les modèles entraînés dans les onglets précédents apparaissent ici — "
|
| 432 |
-
"CNN de zéro comme classifieurs ML. "
|
| 433 |
"Évaluez-les sur le jeu de test, prédisez la classe d'une image importée, "
|
| 434 |
"et tirez vos conclusions sur l'apport du backbone préentraîné."
|
| 435 |
)
|
|
@@ -493,6 +587,20 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 493 |
outputs=image_gallery,
|
| 494 |
)
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
train_cnn_btn.click(
|
| 497 |
fn=train_cnn_callback,
|
| 498 |
inputs=[
|
|
@@ -503,7 +611,7 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 503 |
],
|
| 504 |
outputs=[
|
| 505 |
cnn_logs, cnn_history, cnn_summary,
|
| 506 |
-
cnn_report, cnn_cm, cnn_cm_img,
|
| 507 |
model_selector,
|
| 508 |
],
|
| 509 |
)
|
|
@@ -513,18 +621,18 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
|
|
| 513 |
clf_type.change(
|
| 514 |
fn=on_clf_type_change,
|
| 515 |
inputs=clf_type,
|
| 516 |
-
outputs=[svm_col, logreg_col, knn_col, rf_col
|
| 517 |
)
|
| 518 |
|
| 519 |
train_classical_btn.click(
|
| 520 |
fn=train_classical_callback,
|
| 521 |
inputs=[
|
| 522 |
clf_type,
|
| 523 |
-
svm_c,
|
| 524 |
-
logreg_c,
|
| 525 |
-
knn_k,
|
| 526 |
-
rf_n_estimators,
|
| 527 |
-
|
| 528 |
ml_tag,
|
| 529 |
],
|
| 530 |
outputs=[ml_summary, ml_report, ml_cm, ml_cm_img, model_selector],
|
|
|
|
| 12 |
list_saved_models,
|
| 13 |
model_meta_path,
|
| 14 |
train_cnn,
|
| 15 |
+
train_mlp,
|
| 16 |
)
|
| 17 |
|
| 18 |
# ---------------------------------------------------------------------------
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
# ---------------------------------------------------------------------------
|
| 39 |
+
# Tab 2 — MLP (baseline)
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
@spaces.GPU(duration=300)
|
| 43 |
+
def train_mlp_callback(
|
| 44 |
+
num_layers, hidden_dim, dropout,
|
| 45 |
+
learning_rate, weight_decay, batch_size, epochs,
|
| 46 |
+
model_tag,
|
| 47 |
+
):
|
| 48 |
+
try:
|
| 49 |
+
result = train_mlp(
|
| 50 |
+
num_layers=int(num_layers),
|
| 51 |
+
hidden_dim=int(hidden_dim),
|
| 52 |
+
dropout=float(dropout),
|
| 53 |
+
learning_rate=float(learning_rate),
|
| 54 |
+
weight_decay=float(weight_decay),
|
| 55 |
+
batch_size=int(batch_size),
|
| 56 |
+
epochs=int(epochs),
|
| 57 |
+
model_tag=model_tag,
|
| 58 |
+
)
|
| 59 |
+
models = list_saved_models()
|
| 60 |
+
selected = result["model_name"] if result["model_name"] in models else None
|
| 61 |
+
return (
|
| 62 |
+
result["logs"],
|
| 63 |
+
result["history"],
|
| 64 |
+
result["summary"],
|
| 65 |
+
result["classification_report"],
|
| 66 |
+
result["confusion_matrix"],
|
| 67 |
+
result["confusion_matrix_path"],
|
| 68 |
+
result["loss_curve_path"],
|
| 69 |
+
gr.update(choices=models, value=selected),
|
| 70 |
+
)
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return f"Échec de l'entraînement :\n{e}", None, None, None, None, None, None, gr.update()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Tab 3 — SimpleCNN
|
| 77 |
# ---------------------------------------------------------------------------
|
| 78 |
|
| 79 |
@spaces.GPU(duration=300)
|
|
|
|
| 106 |
result["classification_report"],
|
| 107 |
result["confusion_matrix"],
|
| 108 |
result["confusion_matrix_path"],
|
| 109 |
+
result["loss_curve_path"],
|
| 110 |
gr.update(choices=models, value=selected),
|
| 111 |
)
|
| 112 |
except Exception as e:
|
| 113 |
+
return f"Échec de l'entraînement :\n{e}", None, None, None, None, None, None, gr.update()
|
| 114 |
|
| 115 |
|
| 116 |
# ---------------------------------------------------------------------------
|
| 117 |
+
# Tab 4 — Backbone + ML classique
|
| 118 |
# ---------------------------------------------------------------------------
|
| 119 |
|
| 120 |
@spaces.GPU(duration=60)
|
|
|
|
| 131 |
|
| 132 |
def on_clf_type_change(clf_type):
|
| 133 |
show = lambda t: gr.update(visible=(clf_type == t))
|
| 134 |
+
return show("SVM"), show("Régression logistique"), show("k-NN"), show("Forêt aléatoire")
|
| 135 |
|
| 136 |
|
| 137 |
def train_classical_callback(
|
| 138 |
clf_type,
|
| 139 |
+
svm_c,
|
| 140 |
+
logreg_c,
|
| 141 |
+
knn_k,
|
| 142 |
+
rf_n_estimators,
|
| 143 |
+
use_cv,
|
| 144 |
model_tag,
|
| 145 |
):
|
| 146 |
try:
|
|
|
|
| 153 |
|
| 154 |
params = {}
|
| 155 |
if clf_type == "SVM":
|
| 156 |
+
params = {"C": float(svm_c)}
|
| 157 |
elif clf_type == "Régression logistique":
|
| 158 |
+
params = {"C": float(logreg_c)}
|
| 159 |
elif clf_type == "k-NN":
|
| 160 |
+
params = {"n_neighbors": int(knn_k)}
|
| 161 |
elif clf_type == "Forêt aléatoire":
|
| 162 |
+
params = {"n_estimators": int(rf_n_estimators)}
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
class_names = get_class_names()
|
| 165 |
+
result = train_classical_model(
|
| 166 |
+
clf_type, features_cache, class_names, model_tag, use_cv=bool(use_cv), **params
|
| 167 |
+
)
|
| 168 |
|
| 169 |
models = list_saved_models()
|
| 170 |
selected = result["model_name"] if result["model_name"] in models else None
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
# ---------------------------------------------------------------------------
|
| 183 |
+
# Tab 5 — Tester et analyser
|
| 184 |
# ---------------------------------------------------------------------------
|
| 185 |
|
| 186 |
def refresh_models_callback():
|
|
|
|
| 233 |
|
| 234 |
gr.Markdown("# Classification d'images microscopiques de charbons de bois")
|
| 235 |
gr.Markdown(
|
| 236 |
+
"Ce parcours pédagogique suit une progression en quatre étapes : "
|
| 237 |
+
"**exploration des données**, **MLP de référence**, **CNN entraîné de zéro**, "
|
| 238 |
"puis **exploitation d'un backbone préentraîné avec des algorithmes classiques**. "
|
| 239 |
+
"L'objectif est de comprendre pourquoi la structure convolutive et l'apprentissage par "
|
| 240 |
+
"transfert sont si puissants, surtout quand les données sont rares."
|
| 241 |
)
|
| 242 |
|
| 243 |
with gr.Tabs():
|
|
|
|
| 278 |
image_gallery = gr.Gallery(label="Exemples d'images", columns=4, height=600)
|
| 279 |
|
| 280 |
# ------------------------------------------------------------------ #
|
| 281 |
+
# Tab 2 — MLP (modèle de référence)
|
| 282 |
+
# ------------------------------------------------------------------ #
|
| 283 |
+
with gr.Tab("2. MLP (modèle de référence)"):
|
| 284 |
+
gr.Markdown("## Un premier réseau de neurones : le perceptron multicouche")
|
| 285 |
+
gr.Markdown(
|
| 286 |
+
"Avant d'introduire un CNN, commençons par le modèle le plus simple : un **MLP** "
|
| 287 |
+
"(perceptron multicouche), entièrement connecté. Chaque image est aplatie en un long "
|
| 288 |
+
"vecteur de pixels — le réseau ne sait donc rien de la structure spatiale de l'image "
|
| 289 |
+
"(voisinage des pixels, formes, textures locales).\n\n"
|
| 290 |
+
"**Contexte du problème :** notre jeu de données contient 39 espèces, "
|
| 291 |
+
"avec seulement 8 images par espèce en moyenne, réparties en train / validation / test.\n\n"
|
| 292 |
+
"**Ce que cet exercice doit montrer :** observez les courbes de perte train vs validation. "
|
| 293 |
+
"À partir de quelle époque la courbe de validation cesse de s'améliorer (ou remonte) "
|
| 294 |
+
"pendant que la perte d'entraînement continue de baisser ? C'est le signe du surapprentissage. "
|
| 295 |
+
"Essayez de faire varier le nombre de couches et le nombre de neurones par couche : "
|
| 296 |
+
"vous constaterez que le surapprentissage apparaît quel que soit le choix — "
|
| 297 |
+
"un MLP n'exploite pas la structure de l'image et ne peut pas s'en affranchir. "
|
| 298 |
+
"C'est cette limite qui motive le passage au CNN."
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
with gr.Row():
|
| 302 |
+
with gr.Column():
|
| 303 |
+
gr.Markdown("#### Architecture du MLP")
|
| 304 |
+
mlp_num_layers = gr.Slider(
|
| 305 |
+
minimum=1, maximum=5, value=2, step=1,
|
| 306 |
+
label="Nombre de couches cachées",
|
| 307 |
+
)
|
| 308 |
+
mlp_hidden_dim = gr.Dropdown(
|
| 309 |
+
choices=[64, 128, 256, 512, 1024], value=256,
|
| 310 |
+
label="Neurones par couche cachée",
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
gr.Markdown("#### Hyperparamètres d'entraînement")
|
| 314 |
+
mlp_dropout = gr.Slider(
|
| 315 |
+
minimum=0.0, maximum=0.8, value=0.4, step=0.05,
|
| 316 |
+
label="Dropout",
|
| 317 |
+
)
|
| 318 |
+
mlp_lr = gr.Number(value=1e-3, label="Taux d'apprentissage")
|
| 319 |
+
mlp_wd = gr.Number(value=1e-4, label="Weight decay (régularisation L2)")
|
| 320 |
+
mlp_bs = gr.Dropdown(choices=[8, 16, 32, 64], value=16, label="Taille du batch")
|
| 321 |
+
mlp_epochs = gr.Slider(
|
| 322 |
+
minimum=1, maximum=50, value=30, step=1, label="Nombre d'époques"
|
| 323 |
+
)
|
| 324 |
+
mlp_tag = gr.Textbox(
|
| 325 |
+
label="Nom du modèle", placeholder="ex. mlp_2couches_256"
|
| 326 |
+
)
|
| 327 |
+
train_mlp_btn = gr.Button("Lancer l'entraînement", variant="primary")
|
| 328 |
+
|
| 329 |
+
with gr.Column():
|
| 330 |
+
mlp_logs = gr.Textbox(label="Journal d'entraînement", lines=20)
|
| 331 |
+
mlp_history = gr.JSON(label="Historique époque par époque")
|
| 332 |
+
mlp_summary = gr.JSON(label="Résumé final")
|
| 333 |
+
|
| 334 |
+
gr.Markdown("## Courbes de perte (train vs validation)")
|
| 335 |
+
mlp_loss_curve = gr.Image(label="Perte par époque", type="filepath")
|
| 336 |
+
|
| 337 |
+
gr.Markdown("## Résultats sur le jeu de test")
|
| 338 |
+
mlp_report = gr.Dataframe(label="Rapport de classification", interactive=False)
|
| 339 |
+
mlp_cm = gr.Dataframe(label="Matrice de confusion", interactive=False)
|
| 340 |
+
mlp_cm_img = gr.Image(label="Matrice de confusion — figure", type="filepath")
|
| 341 |
+
|
| 342 |
+
# ------------------------------------------------------------------ #
|
| 343 |
+
# Tab 3 — SimpleCNN de zéro
|
| 344 |
# ------------------------------------------------------------------ #
|
| 345 |
+
with gr.Tab("3. CNN entraîné de zéro"):
|
| 346 |
gr.Markdown("## Entraîner un réseau convolutif sans connaissances préalables")
|
| 347 |
gr.Markdown(
|
| 348 |
+
"Le MLP de l'onglet précédent surapprend quels que soient les hyperparamètres choisis : "
|
| 349 |
+
"il ne peut pas exploiter la structure spatiale des images. Construisons maintenant un "
|
| 350 |
+
"réseau de neurones convolutif (CNN), conçu pour capter des motifs locaux (contours, "
|
| 351 |
+
"textures) grâce aux filtres de convolution, et entraînons-le directement sur nos données "
|
| 352 |
+
"de charbons de bois. Ce réseau part de paramètres aléatoires : il ne sait rien des images "
|
| 353 |
+
"au départ.\n\n"
|
| 354 |
"**Contexte du problème :** notre jeu de données contient 39 espèces, "
|
| 355 |
"avec seulement 8 images par espèce en moyenne. "
|
| 356 |
"C'est extrêmement peu pour apprendre à distinguer 39 classes visuellement similaires.\n\n"
|
|
|
|
| 408 |
cnn_history = gr.JSON(label="Historique époque par époque")
|
| 409 |
cnn_summary = gr.JSON(label="Résumé final")
|
| 410 |
|
| 411 |
+
gr.Markdown("## Courbes de perte (train vs validation)")
|
| 412 |
+
cnn_loss_curve = gr.Image(label="Perte par époque", type="filepath")
|
| 413 |
+
|
| 414 |
gr.Markdown("## Résultats sur le jeu de test")
|
| 415 |
cnn_report = gr.Dataframe(label="Rapport de classification", interactive=False)
|
| 416 |
cnn_cm = gr.Dataframe(label="Matrice de confusion", interactive=False)
|
| 417 |
cnn_cm_img = gr.Image(label="Matrice de confusion — figure", type="filepath")
|
| 418 |
|
| 419 |
# ------------------------------------------------------------------ #
|
| 420 |
+
# Tab 4 — Backbone préentraîné + ML classique
|
| 421 |
# ------------------------------------------------------------------ #
|
| 422 |
+
with gr.Tab("4. Backbone préentraîné + ML classique"):
|
| 423 |
gr.Markdown("## Exploiter les connaissances d'un modèle préentraîné")
|
| 424 |
gr.Markdown(
|
| 425 |
+
"Face aux limites observées avec le MLP et le CNN de zéro (peu de données, beaucoup de "
|
| 426 |
+
"classes), une stratégie radicalement différente consiste à réutiliser un réseau déjà "
|
| 427 |
+
"entraîné sur d'autres images, et à s'appuyer sur les représentations qu'il a apprises.\n\n"
|
| 428 |
"### Qu'est-ce qu'un backbone ?\n"
|
| 429 |
"Un **backbone** est un réseau convolutif dont on retire la couche de classification finale. "
|
| 430 |
"Il agit comme un extracteur de caractéristiques : pour chaque image en entrée, "
|
| 431 |
"il produit un vecteur de nombres (ici **512 dimensions**) qui encode le contenu visuel "
|
| 432 |
"de l'image de façon compacte et abstraite.\n\n"
|
| 433 |
"### Quel backbone utilisons-nous ici ?\n"
|
| 434 |
+
"Nous utilisons un **ResNet18 avec ses poids ImageNet d'origine, sans aucun fine-tuning** "
|
| 435 |
+
"sur nos images de charbons de bois. Ce modèle a été préentraîné sur ImageNet "
|
| 436 |
+
"(1,2 million d'images, 1 000 classes) — il n'a jamais vu une image de charbon de bois. "
|
| 437 |
+
"L'objectif est d'observer si des représentations apprises sur des images naturelles "
|
| 438 |
+
"génériques transfèrent malgré tout à un domaine très différent (microscopie).\n\n"
|
| 439 |
"### Pourquoi des algorithmes classiques ensuite ?\n"
|
| 440 |
"Une fois les images transformées en vecteurs de 512 dimensions, "
|
| 441 |
"n'importe quel algorithme de classification classique peut être appliqué. "
|
| 442 |
+
"Ces algorithmes (SVM, régression logistique, k-NN, forêt aléatoire) sont rapides à entraîner, "
|
| 443 |
"interprétables, et ne nécessitent pas de GPU. "
|
| 444 |
+
"Pour chaque algorithme, un seul hyperparamètre est ajustable — les autres réglages "
|
| 445 |
+
"sont fixés pour rester comparables. Une option de validation croisée permet en plus "
|
| 446 |
+
"d'estimer la stabilité du score sur le train set. "
|
| 447 |
+
"Comparez leurs résultats avec ceux obtenus aux étapes précédentes (MLP, CNN)."
|
| 448 |
)
|
| 449 |
|
| 450 |
gr.Markdown("## Étape 1 — Extraction des caractéristiques")
|
|
|
|
| 468 |
with gr.Row():
|
| 469 |
with gr.Column():
|
| 470 |
clf_type = gr.Radio(
|
| 471 |
+
choices=["SVM", "Régression logistique", "k-NN", "Forêt aléatoire"],
|
| 472 |
value="SVM",
|
| 473 |
label="Algorithme de classification",
|
| 474 |
)
|
| 475 |
|
| 476 |
with gr.Column(visible=True) as svm_col:
|
| 477 |
+
gr.Markdown("#### Paramètre SVM (noyau RBF, gamma='scale' fixés)")
|
| 478 |
svm_c = gr.Number(
|
| 479 |
value=1.0, label="C — force de régularisation",
|
| 480 |
info="Une valeur faible regularise davantage (marges plus larges).",
|
| 481 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
with gr.Column(visible=False) as logreg_col:
|
| 484 |
+
gr.Markdown("#### Paramètre Régression logistique (max_iter=1000 fixé)")
|
| 485 |
logreg_c = gr.Number(value=1.0, label="C — force de régularisation")
|
|
|
|
| 486 |
|
| 487 |
with gr.Column(visible=False) as knn_col:
|
| 488 |
+
gr.Markdown("#### Paramètre k-NN (distance euclidienne fixée)")
|
| 489 |
knn_k = gr.Slider(
|
| 490 |
minimum=1, maximum=20, value=5, step=1,
|
| 491 |
label="k — nombre de voisins",
|
| 492 |
info="k=1 mémorise les données, k élevé généralise davantage.",
|
| 493 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
|
| 495 |
with gr.Column(visible=False) as rf_col:
|
| 496 |
+
gr.Markdown("#### Paramètre Forêt aléatoire (profondeur illimitée fixée)")
|
| 497 |
rf_n_estimators = gr.Slider(
|
| 498 |
minimum=10, maximum=500, value=100, step=10, label="Nombre d'arbres"
|
| 499 |
)
|
|
|
|
|
|
|
|
|
|
| 500 |
|
| 501 |
+
cv_checkbox = gr.Checkbox(
|
| 502 |
+
value=False,
|
| 503 |
+
label="Activer la validation croisée sur le train set",
|
| 504 |
+
info="Ajoute un score F1 macro moyenné sur plusieurs folds, en plus du score sur le jeu de test.",
|
| 505 |
+
)
|
| 506 |
|
| 507 |
ml_tag = gr.Textbox(
|
| 508 |
+
label="Nom du modèle", placeholder="ex. svm_C1"
|
| 509 |
)
|
| 510 |
train_classical_btn = gr.Button("Entraîner le classifieur", variant="primary")
|
| 511 |
|
|
|
|
| 517 |
ml_cm_img = gr.Image(label="Matrice de confusion — figure", type="filepath")
|
| 518 |
|
| 519 |
# ------------------------------------------------------------------ #
|
| 520 |
+
# Tab 5 — Tester et analyser
|
| 521 |
# ------------------------------------------------------------------ #
|
| 522 |
+
with gr.Tab("5. Tester et analyser"):
|
| 523 |
gr.Markdown("## Comparer et évaluer les modèles")
|
| 524 |
gr.Markdown(
|
| 525 |
"Tous les modèles entraînés dans les onglets précédents apparaissent ici — "
|
| 526 |
+
"MLP, CNN de zéro comme classifieurs ML. "
|
| 527 |
"Évaluez-les sur le jeu de test, prédisez la classe d'une image importée, "
|
| 528 |
"et tirez vos conclusions sur l'apport du backbone préentraîné."
|
| 529 |
)
|
|
|
|
| 587 |
outputs=image_gallery,
|
| 588 |
)
|
| 589 |
|
| 590 |
+
train_mlp_btn.click(
|
| 591 |
+
fn=train_mlp_callback,
|
| 592 |
+
inputs=[
|
| 593 |
+
mlp_num_layers, mlp_hidden_dim, mlp_dropout,
|
| 594 |
+
mlp_lr, mlp_wd, mlp_bs, mlp_epochs,
|
| 595 |
+
mlp_tag,
|
| 596 |
+
],
|
| 597 |
+
outputs=[
|
| 598 |
+
mlp_logs, mlp_history, mlp_summary,
|
| 599 |
+
mlp_report, mlp_cm, mlp_cm_img, mlp_loss_curve,
|
| 600 |
+
model_selector,
|
| 601 |
+
],
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
train_cnn_btn.click(
|
| 605 |
fn=train_cnn_callback,
|
| 606 |
inputs=[
|
|
|
|
| 611 |
],
|
| 612 |
outputs=[
|
| 613 |
cnn_logs, cnn_history, cnn_summary,
|
| 614 |
+
cnn_report, cnn_cm, cnn_cm_img, cnn_loss_curve,
|
| 615 |
model_selector,
|
| 616 |
],
|
| 617 |
)
|
|
|
|
| 621 |
clf_type.change(
|
| 622 |
fn=on_clf_type_change,
|
| 623 |
inputs=clf_type,
|
| 624 |
+
outputs=[svm_col, logreg_col, knn_col, rf_col],
|
| 625 |
)
|
| 626 |
|
| 627 |
train_classical_btn.click(
|
| 628 |
fn=train_classical_callback,
|
| 629 |
inputs=[
|
| 630 |
clf_type,
|
| 631 |
+
svm_c,
|
| 632 |
+
logreg_c,
|
| 633 |
+
knn_k,
|
| 634 |
+
rf_n_estimators,
|
| 635 |
+
cv_checkbox,
|
| 636 |
ml_tag,
|
| 637 |
],
|
| 638 |
outputs=[ml_summary, ml_report, ml_cm, ml_cm_img, model_selector],
|
backbone_utils.py
CHANGED
|
@@ -3,12 +3,9 @@ import os
|
|
| 3 |
import numpy as np
|
| 4 |
import torch
|
| 5 |
import torch.nn as nn
|
| 6 |
-
from huggingface_hub import hf_hub_download
|
| 7 |
from torch.utils.data import DataLoader
|
| 8 |
from torchvision import models
|
| 9 |
|
| 10 |
-
from config import HF_BACKBONE_REPO, HF_TOKEN
|
| 11 |
-
|
| 12 |
_BACKBONE = None
|
| 13 |
_FEATURES_CACHE = None
|
| 14 |
|
|
@@ -17,21 +14,16 @@ _DISK_CACHE_PATH = "/tmp/charcoal_features.npz"
|
|
| 17 |
|
| 18 |
|
| 19 |
def load_backbone(device: torch.device) -> nn.Module:
|
|
|
|
|
|
|
|
|
|
| 20 |
global _BACKBONE
|
| 21 |
|
| 22 |
if _BACKBONE is not None:
|
| 23 |
return _BACKBONE.to(device)
|
| 24 |
|
| 25 |
-
|
| 26 |
-
repo_id=HF_BACKBONE_REPO,
|
| 27 |
-
filename="resnet18_charcoal_backbone.pt",
|
| 28 |
-
token=HF_TOKEN,
|
| 29 |
-
repo_type="model",
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
backbone = models.resnet18()
|
| 33 |
backbone.fc = nn.Identity()
|
| 34 |
-
backbone.load_state_dict(torch.load(pt_path, map_location="cpu"))
|
| 35 |
|
| 36 |
for p in backbone.parameters():
|
| 37 |
p.requires_grad = False
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
import torch
|
| 5 |
import torch.nn as nn
|
|
|
|
| 6 |
from torch.utils.data import DataLoader
|
| 7 |
from torchvision import models
|
| 8 |
|
|
|
|
|
|
|
| 9 |
_BACKBONE = None
|
| 10 |
_FEATURES_CACHE = None
|
| 11 |
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def load_backbone(device: torch.device) -> nn.Module:
|
| 17 |
+
"""ResNet18 avec les poids ImageNet d'origine, sans fine-tuning sur le
|
| 18 |
+
dataset de charbons de bois — utilisé tel quel comme extracteur de
|
| 19 |
+
caractéristiques pour illustrer le transfer learning "brut"."""
|
| 20 |
global _BACKBONE
|
| 21 |
|
| 22 |
if _BACKBONE is not None:
|
| 23 |
return _BACKBONE.to(device)
|
| 24 |
|
| 25 |
+
backbone = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
backbone.fc = nn.Identity()
|
|
|
|
| 27 |
|
| 28 |
for p in backbone.parameters():
|
| 29 |
p.requires_grad = False
|
classical_ml_utils.py
CHANGED
|
@@ -1,18 +1,19 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import List
|
| 5 |
|
| 6 |
import joblib
|
| 7 |
-
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
| 8 |
from sklearn.ensemble import RandomForestClassifier
|
| 9 |
from sklearn.linear_model import LogisticRegression
|
|
|
|
| 10 |
from sklearn.neighbors import KNeighborsClassifier
|
| 11 |
from sklearn.pipeline import Pipeline
|
| 12 |
from sklearn.preprocessing import StandardScaler
|
| 13 |
from sklearn.svm import SVC
|
| 14 |
|
| 15 |
-
from config import MODEL_DIR, META_DIR
|
| 16 |
from metrics_utils import compute_classification_metrics, save_confusion_matrix_figure
|
| 17 |
|
| 18 |
CLF_TYPE_MAP = {
|
|
@@ -20,7 +21,6 @@ CLF_TYPE_MAP = {
|
|
| 20 |
"Régression logistique": "logreg",
|
| 21 |
"k-NN": "knn",
|
| 22 |
"Forêt aléatoire": "rf",
|
| 23 |
-
"LDA": "lda",
|
| 24 |
}
|
| 25 |
|
| 26 |
|
|
@@ -55,26 +55,41 @@ def build_pipeline(clf_type: str, **params) -> Pipeline:
|
|
| 55 |
metric=params.get("metric", "euclidean"),
|
| 56 |
)
|
| 57 |
elif key == "rf":
|
| 58 |
-
max_depth = params.get("max_depth") or None
|
| 59 |
clf = RandomForestClassifier(
|
| 60 |
n_estimators=params.get("n_estimators", 100),
|
| 61 |
-
max_depth=
|
| 62 |
random_state=42,
|
| 63 |
n_jobs=-1,
|
| 64 |
)
|
| 65 |
-
elif key == "lda":
|
| 66 |
-
clf = LinearDiscriminantAnalysis(solver=params.get("solver", "svd"))
|
| 67 |
else:
|
| 68 |
raise ValueError(f"Classifieur inconnu : {clf_type}")
|
| 69 |
|
| 70 |
return Pipeline([("scaler", StandardScaler()), ("clf", clf)])
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def train_classical_model(
|
| 74 |
clf_type: str,
|
| 75 |
features_cache: dict,
|
| 76 |
class_names: List[str],
|
| 77 |
model_tag: str = "",
|
|
|
|
| 78 |
**params,
|
| 79 |
):
|
| 80 |
X_train = features_cache["train"]["X"]
|
|
@@ -82,6 +97,8 @@ def train_classical_model(
|
|
| 82 |
X_test = features_cache["test"]["X"]
|
| 83 |
y_test = features_cache["test"]["y"]
|
| 84 |
|
|
|
|
|
|
|
| 85 |
pipeline = build_pipeline(clf_type, **params)
|
| 86 |
pipeline.fit(X_train, y_train)
|
| 87 |
|
|
@@ -109,6 +126,7 @@ def train_classical_model(
|
|
| 109 |
"test_f1_weighted": metrics["f1_weighted"],
|
| 110 |
"train_samples": int(len(X_train)),
|
| 111 |
"test_samples": int(len(X_test)),
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
with open(meta_path(model_name), "w", encoding="utf-8") as f:
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
+
from collections import Counter
|
| 4 |
from datetime import datetime
|
| 5 |
from typing import List
|
| 6 |
|
| 7 |
import joblib
|
|
|
|
| 8 |
from sklearn.ensemble import RandomForestClassifier
|
| 9 |
from sklearn.linear_model import LogisticRegression
|
| 10 |
+
from sklearn.model_selection import StratifiedKFold, cross_val_score
|
| 11 |
from sklearn.neighbors import KNeighborsClassifier
|
| 12 |
from sklearn.pipeline import Pipeline
|
| 13 |
from sklearn.preprocessing import StandardScaler
|
| 14 |
from sklearn.svm import SVC
|
| 15 |
|
| 16 |
+
from config import MODEL_DIR, META_DIR, CV_FOLDS
|
| 17 |
from metrics_utils import compute_classification_metrics, save_confusion_matrix_figure
|
| 18 |
|
| 19 |
CLF_TYPE_MAP = {
|
|
|
|
| 21 |
"Régression logistique": "logreg",
|
| 22 |
"k-NN": "knn",
|
| 23 |
"Forêt aléatoire": "rf",
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
|
|
|
|
| 55 |
metric=params.get("metric", "euclidean"),
|
| 56 |
)
|
| 57 |
elif key == "rf":
|
|
|
|
| 58 |
clf = RandomForestClassifier(
|
| 59 |
n_estimators=params.get("n_estimators", 100),
|
| 60 |
+
max_depth=None,
|
| 61 |
random_state=42,
|
| 62 |
n_jobs=-1,
|
| 63 |
)
|
|
|
|
|
|
|
| 64 |
else:
|
| 65 |
raise ValueError(f"Classifieur inconnu : {clf_type}")
|
| 66 |
|
| 67 |
return Pipeline([("scaler", StandardScaler()), ("clf", clf)])
|
| 68 |
|
| 69 |
|
| 70 |
+
def _cross_validate(clf_type: str, X_train, y_train, **params) -> dict:
|
| 71 |
+
"""CV stratifiée sur le train set. Réduit le nombre de folds si une classe
|
| 72 |
+
a moins d'échantillons que CV_FOLDS (dataset très réduit)."""
|
| 73 |
+
min_class_count = min(Counter(y_train.tolist()).values())
|
| 74 |
+
folds = max(2, min(CV_FOLDS, min_class_count))
|
| 75 |
+
|
| 76 |
+
pipeline = build_pipeline(clf_type, **params)
|
| 77 |
+
skf = StratifiedKFold(n_splits=folds, shuffle=True, random_state=42)
|
| 78 |
+
scores = cross_val_score(pipeline, X_train, y_train, cv=skf, scoring="f1_macro")
|
| 79 |
+
|
| 80 |
+
return {
|
| 81 |
+
"cv_folds": folds,
|
| 82 |
+
"cv_f1_macro_mean": round(float(scores.mean()), 4),
|
| 83 |
+
"cv_f1_macro_std": round(float(scores.std()), 4),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
def train_classical_model(
|
| 88 |
clf_type: str,
|
| 89 |
features_cache: dict,
|
| 90 |
class_names: List[str],
|
| 91 |
model_tag: str = "",
|
| 92 |
+
use_cv: bool = False,
|
| 93 |
**params,
|
| 94 |
):
|
| 95 |
X_train = features_cache["train"]["X"]
|
|
|
|
| 97 |
X_test = features_cache["test"]["X"]
|
| 98 |
y_test = features_cache["test"]["y"]
|
| 99 |
|
| 100 |
+
cv_metrics = _cross_validate(clf_type, X_train, y_train, **params) if use_cv else None
|
| 101 |
+
|
| 102 |
pipeline = build_pipeline(clf_type, **params)
|
| 103 |
pipeline.fit(X_train, y_train)
|
| 104 |
|
|
|
|
| 126 |
"test_f1_weighted": metrics["f1_weighted"],
|
| 127 |
"train_samples": int(len(X_train)),
|
| 128 |
"test_samples": int(len(X_test)),
|
| 129 |
+
**(cv_metrics or {}),
|
| 130 |
}
|
| 131 |
|
| 132 |
with open(meta_path(model_name), "w", encoding="utf-8") as f:
|
config.py
CHANGED
|
@@ -11,7 +11,6 @@ os.makedirs(META_DIR, exist_ok=True)
|
|
| 11 |
os.makedirs(FIGURE_DIR, exist_ok=True)
|
| 12 |
|
| 13 |
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "CircleStar/charcoal-microscopy")
|
| 14 |
-
HF_BACKBONE_REPO = os.environ.get("HF_BACKBONE_REPO", "CircleStar/charcoal-resnet18-backbone")
|
| 15 |
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 16 |
|
| 17 |
IMAGE_SIZE = 224
|
|
@@ -19,4 +18,5 @@ RANDOM_SEED = 42
|
|
| 19 |
|
| 20 |
DATASET_DISPLAY_NAME = "Images microscopiques de charbons de bois"
|
| 21 |
|
| 22 |
-
CLASSICAL_MODEL_TYPES = frozenset({"svm", "logreg", "knn", "rf"
|
|
|
|
|
|
| 11 |
os.makedirs(FIGURE_DIR, exist_ok=True)
|
| 12 |
|
| 13 |
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "CircleStar/charcoal-microscopy")
|
|
|
|
| 14 |
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 15 |
|
| 16 |
IMAGE_SIZE = 224
|
|
|
|
| 18 |
|
| 19 |
DATASET_DISPLAY_NAME = "Images microscopiques de charbons de bois"
|
| 20 |
|
| 21 |
+
CLASSICAL_MODEL_TYPES = frozenset({"svm", "logreg", "knn", "rf"})
|
| 22 |
+
CV_FOLDS = 3
|
finetune_backbone.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
finetune_backbone.py
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
Fine-tune ResNet18 (ImageNet) on the local charcoal microscopy dataset.
|
| 5 |
Goal: produce a domain-adapted backbone for students to use as a frozen
|
| 6 |
feature extractor. The full dataset is used intentionally — this is a
|
|
|
|
| 1 |
"""
|
| 2 |
finetune_backbone.py
|
| 3 |
|
| 4 |
+
NOT USED by the current app — backbone_utils.load_backbone() now loads plain
|
| 5 |
+
ImageNet ResNet18 weights directly (no fine-tuning), per the pedagogical
|
| 6 |
+
redesign. Kept for reference in case a domain-adapted backbone is reinstated.
|
| 7 |
+
|
| 8 |
Fine-tune ResNet18 (ImageNet) on the local charcoal microscopy dataset.
|
| 9 |
Goal: produce a domain-adapted backbone for students to use as a frozen
|
| 10 |
feature extractor. The full dataset is used intentionally — this is a
|
metrics_utils.py
CHANGED
|
@@ -77,4 +77,26 @@ def save_confusion_matrix_figure(cm_df: pd.DataFrame, model_name: str) -> str:
|
|
| 77 |
plt.savefig(fig_path, dpi=200)
|
| 78 |
plt.close()
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
return fig_path
|
|
|
|
| 77 |
plt.savefig(fig_path, dpi=200)
|
| 78 |
plt.close()
|
| 79 |
|
| 80 |
+
return fig_path
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def save_loss_curve_figure(history: List[dict], model_name: str) -> str:
|
| 84 |
+
fig_path = os.path.join(FIGURE_DIR, f"{model_name}_loss_curve.png")
|
| 85 |
+
|
| 86 |
+
epochs = [h["epoch"] for h in history]
|
| 87 |
+
train_loss = [h["train_loss"] for h in history]
|
| 88 |
+
val_loss = [h["val_loss"] for h in history]
|
| 89 |
+
|
| 90 |
+
plt.figure(figsize=(8, 5))
|
| 91 |
+
plt.plot(epochs, train_loss, label="Train", marker="o", markersize=3)
|
| 92 |
+
plt.plot(epochs, val_loss, label="Validation", marker="o", markersize=3)
|
| 93 |
+
plt.xlabel("Époque")
|
| 94 |
+
plt.ylabel("Perte (loss)")
|
| 95 |
+
plt.title("Courbes de perte — train vs validation")
|
| 96 |
+
plt.legend()
|
| 97 |
+
plt.grid(alpha=0.3)
|
| 98 |
+
plt.tight_layout()
|
| 99 |
+
plt.savefig(fig_path, dpi=150)
|
| 100 |
+
plt.close()
|
| 101 |
+
|
| 102 |
return fig_path
|
model.py
CHANGED
|
@@ -19,6 +19,35 @@ class BackboneWithFC(nn.Module):
|
|
| 19 |
return self.classifier(self.backbone(x))
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
class SimpleCNN(nn.Module):
|
| 23 |
def __init__(
|
| 24 |
self,
|
|
|
|
| 19 |
return self.classifier(self.backbone(x))
|
| 20 |
|
| 21 |
|
| 22 |
+
class MLP(nn.Module):
|
| 23 |
+
"""Baseline entièrement connecté : montre les limites d'un MLP sur des images
|
| 24 |
+
(aucune structure spatiale exploitée) avant d'introduire le CNN."""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
num_classes: int,
|
| 29 |
+
input_size: int,
|
| 30 |
+
num_layers: int = 2,
|
| 31 |
+
hidden_dim: int = 256,
|
| 32 |
+
dropout: float = 0.4,
|
| 33 |
+
):
|
| 34 |
+
super().__init__()
|
| 35 |
+
|
| 36 |
+
layers = [nn.Flatten()]
|
| 37 |
+
in_dim = input_size
|
| 38 |
+
for _ in range(num_layers):
|
| 39 |
+
layers.append(nn.Linear(in_dim, hidden_dim))
|
| 40 |
+
layers.append(nn.ReLU(inplace=True))
|
| 41 |
+
layers.append(nn.Dropout(dropout))
|
| 42 |
+
in_dim = hidden_dim
|
| 43 |
+
layers.append(nn.Linear(in_dim, num_classes))
|
| 44 |
+
|
| 45 |
+
self.net = nn.Sequential(*layers)
|
| 46 |
+
|
| 47 |
+
def forward(self, x):
|
| 48 |
+
return self.net(x)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
class SimpleCNN(nn.Module):
|
| 52 |
def __init__(
|
| 53 |
self,
|
train_utils.py
CHANGED
|
@@ -8,10 +8,10 @@ import torch
|
|
| 8 |
import torch.nn as nn
|
| 9 |
import torch.optim as optim
|
| 10 |
|
| 11 |
-
from config import MODEL_DIR, META_DIR, DATASET_DISPLAY_NAME, CLASSICAL_MODEL_TYPES
|
| 12 |
from data_utils import make_loaders
|
| 13 |
-
from metrics_utils import compute_classification_metrics, save_confusion_matrix_figure
|
| 14 |
-
from model import SimpleCNN, BackboneWithFC
|
| 15 |
|
| 16 |
|
| 17 |
# ---------------------------------------------------------------------------
|
|
@@ -101,6 +101,16 @@ def load_model(model_name: str, device: torch.device) -> Tuple[nn.Module, dict]:
|
|
| 101 |
)
|
| 102 |
model.load_state_dict(torch.load(model_weight_path(model_name), map_location="cpu"))
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
else:
|
| 105 |
raise ValueError(f"load_model n'accepte pas le type '{model_type}'. Utilisez load_classical_pipeline pour les modèles ML classiques.")
|
| 106 |
|
|
@@ -351,6 +361,7 @@ def train_cnn(
|
|
| 351 |
model_name = f"{safe_tag}_{timestamp}"
|
| 352 |
|
| 353 |
cm_path = save_confusion_matrix_figure(metrics["confusion_matrix"], model_name)
|
|
|
|
| 354 |
|
| 355 |
architecture = f"CNN simple ({num_conv_blocks} blocs, filtres={base_filters}, noyau={kernel_size}×{kernel_size})"
|
| 356 |
|
|
@@ -409,6 +420,120 @@ def train_cnn(
|
|
| 409 |
"classification_report": metrics["classification_report"],
|
| 410 |
"confusion_matrix": metrics["confusion_matrix"],
|
| 411 |
"confusion_matrix_path": cm_path,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
}
|
| 413 |
|
| 414 |
|
|
|
|
| 8 |
import torch.nn as nn
|
| 9 |
import torch.optim as optim
|
| 10 |
|
| 11 |
+
from config import MODEL_DIR, META_DIR, DATASET_DISPLAY_NAME, CLASSICAL_MODEL_TYPES, IMAGE_SIZE
|
| 12 |
from data_utils import make_loaders
|
| 13 |
+
from metrics_utils import compute_classification_metrics, save_confusion_matrix_figure, save_loss_curve_figure
|
| 14 |
+
from model import SimpleCNN, BackboneWithFC, MLP
|
| 15 |
|
| 16 |
|
| 17 |
# ---------------------------------------------------------------------------
|
|
|
|
| 101 |
)
|
| 102 |
model.load_state_dict(torch.load(model_weight_path(model_name), map_location="cpu"))
|
| 103 |
|
| 104 |
+
elif model_type == "mlp":
|
| 105 |
+
model = MLP(
|
| 106 |
+
num_classes=cfg["num_classes"],
|
| 107 |
+
input_size=cfg.get("input_size", 3 * IMAGE_SIZE * IMAGE_SIZE),
|
| 108 |
+
num_layers=cfg.get("num_layers", 2),
|
| 109 |
+
hidden_dim=cfg.get("hidden_dim", 256),
|
| 110 |
+
dropout=cfg.get("dropout", 0.4),
|
| 111 |
+
)
|
| 112 |
+
model.load_state_dict(torch.load(model_weight_path(model_name), map_location="cpu"))
|
| 113 |
+
|
| 114 |
else:
|
| 115 |
raise ValueError(f"load_model n'accepte pas le type '{model_type}'. Utilisez load_classical_pipeline pour les modèles ML classiques.")
|
| 116 |
|
|
|
|
| 361 |
model_name = f"{safe_tag}_{timestamp}"
|
| 362 |
|
| 363 |
cm_path = save_confusion_matrix_figure(metrics["confusion_matrix"], model_name)
|
| 364 |
+
loss_curve_path = save_loss_curve_figure(history, model_name)
|
| 365 |
|
| 366 |
architecture = f"CNN simple ({num_conv_blocks} blocs, filtres={base_filters}, noyau={kernel_size}×{kernel_size})"
|
| 367 |
|
|
|
|
| 420 |
"classification_report": metrics["classification_report"],
|
| 421 |
"confusion_matrix": metrics["confusion_matrix"],
|
| 422 |
"confusion_matrix_path": cm_path,
|
| 423 |
+
"loss_curve_path": loss_curve_path,
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
# ---------------------------------------------------------------------------
|
| 428 |
+
# Train MLP from scratch (baseline avant le CNN)
|
| 429 |
+
# ---------------------------------------------------------------------------
|
| 430 |
+
|
| 431 |
+
def train_mlp(
|
| 432 |
+
num_layers: int = 2,
|
| 433 |
+
hidden_dim: int = 256,
|
| 434 |
+
dropout: float = 0.4,
|
| 435 |
+
learning_rate: float = 1e-3,
|
| 436 |
+
weight_decay: float = 1e-4,
|
| 437 |
+
batch_size: int = 16,
|
| 438 |
+
epochs: int = 30,
|
| 439 |
+
model_tag: str = "",
|
| 440 |
+
):
|
| 441 |
+
device = get_runtime_device()
|
| 442 |
+
train_loader, val_loader, test_loader, class_names = make_loaders(batch_size)
|
| 443 |
+
num_classes = len(class_names)
|
| 444 |
+
input_size = 3 * IMAGE_SIZE * IMAGE_SIZE
|
| 445 |
+
|
| 446 |
+
model = MLP(
|
| 447 |
+
num_classes=num_classes,
|
| 448 |
+
input_size=input_size,
|
| 449 |
+
num_layers=num_layers,
|
| 450 |
+
hidden_dim=hidden_dim,
|
| 451 |
+
dropout=dropout,
|
| 452 |
+
).to(device)
|
| 453 |
+
|
| 454 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 455 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 456 |
+
|
| 457 |
+
criterion = nn.CrossEntropyLoss()
|
| 458 |
+
optimizer = optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
|
| 459 |
+
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
|
| 460 |
+
optimizer, mode="min", factor=0.5, patience=8, min_lr=learning_rate * 0.2
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
t0 = time.time()
|
| 464 |
+
history, logs, best_state, best_val_loss = _training_loop(
|
| 465 |
+
model, train_loader, val_loader, criterion, optimizer, scheduler, epochs, device
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
model.load_state_dict(best_state)
|
| 469 |
+
test_loss, test_acc = evaluate_loss_acc(model, test_loader, criterion, device)
|
| 470 |
+
y_true, y_pred = collect_predictions(model, test_loader, device)
|
| 471 |
+
metrics = compute_classification_metrics(y_true, y_pred, class_names)
|
| 472 |
+
elapsed = time.time() - t0
|
| 473 |
+
|
| 474 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 475 |
+
safe_tag = model_tag.strip().replace(" ", "_") if model_tag.strip() else "mlp"
|
| 476 |
+
model_name = f"{safe_tag}_{timestamp}"
|
| 477 |
+
|
| 478 |
+
cm_path = save_confusion_matrix_figure(metrics["confusion_matrix"], model_name)
|
| 479 |
+
loss_curve_path = save_loss_curve_figure(history, model_name)
|
| 480 |
+
|
| 481 |
+
architecture = f"MLP ({num_layers} couches cachées de {hidden_dim} neurones)"
|
| 482 |
+
|
| 483 |
+
config = {
|
| 484 |
+
"dataset_name": DATASET_DISPLAY_NAME,
|
| 485 |
+
"model_type": "mlp",
|
| 486 |
+
"architecture": architecture,
|
| 487 |
+
"num_classes": num_classes,
|
| 488 |
+
"class_names": class_names,
|
| 489 |
+
"input_size": input_size,
|
| 490 |
+
"num_layers": num_layers,
|
| 491 |
+
"hidden_dim": hidden_dim,
|
| 492 |
+
"dropout": dropout,
|
| 493 |
+
"learning_rate": learning_rate,
|
| 494 |
+
"weight_decay": weight_decay,
|
| 495 |
+
"batch_size": batch_size,
|
| 496 |
+
"epochs": epochs,
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
training_summary = {
|
| 500 |
+
"final_train_loss": history[-1]["train_loss"] if history else None,
|
| 501 |
+
"final_train_acc": history[-1]["train_acc"] if history else None,
|
| 502 |
+
"best_val_loss": round(best_val_loss, 4),
|
| 503 |
+
"final_val_loss": history[-1]["val_loss"] if history else None,
|
| 504 |
+
"final_val_acc": history[-1]["val_acc"] if history else None,
|
| 505 |
+
"test_cross_entropy_loss": round(test_loss, 4),
|
| 506 |
+
"test_accuracy": round(test_acc, 4),
|
| 507 |
+
"test_f1_macro": metrics["f1_macro"],
|
| 508 |
+
"test_f1_weighted": metrics["f1_weighted"],
|
| 509 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 510 |
+
"device": str(device),
|
| 511 |
+
"total_params": total_params,
|
| 512 |
+
"trainable_params": trainable_params,
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
save_model(model, model_name, config, training_summary)
|
| 516 |
+
|
| 517 |
+
logs += [
|
| 518 |
+
"",
|
| 519 |
+
"Entraînement terminé.",
|
| 520 |
+
f"Modèle sauvegardé : {model_name}",
|
| 521 |
+
f"Architecture : {architecture}",
|
| 522 |
+
f"Paramètres : {total_params}",
|
| 523 |
+
f"Perte test : {test_loss:.4f} | Accuracy test : {test_acc:.4f}",
|
| 524 |
+
f"F1 macro : {metrics['f1_macro']:.4f} | F1 pondéré : {metrics['f1_weighted']:.4f}",
|
| 525 |
+
f"Temps : {elapsed:.1f}s | Appareil : {device}",
|
| 526 |
+
]
|
| 527 |
+
|
| 528 |
+
return {
|
| 529 |
+
"logs": "\n".join(logs),
|
| 530 |
+
"history": history,
|
| 531 |
+
"summary": training_summary,
|
| 532 |
+
"model_name": model_name,
|
| 533 |
+
"classification_report": metrics["classification_report"],
|
| 534 |
+
"confusion_matrix": metrics["confusion_matrix"],
|
| 535 |
+
"confusion_matrix_path": cm_path,
|
| 536 |
+
"loss_curve_path": loss_curve_path,
|
| 537 |
}
|
| 538 |
|
| 539 |
|