functionNormally commited on
Commit
e7db87b
·
1 Parent(s): c7f3e02

Ajouter MLP baseline, courbes de perte, CV et backbone ImageNet brut

Browse files

Suite 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

Files changed (8) hide show
  1. app.py +183 -75
  2. backbone_utils.py +4 -12
  3. classical_ml_utils.py +25 -7
  4. config.py +2 -2
  5. finetune_backbone.py +4 -0
  6. metrics_utils.py +22 -0
  7. model.py +29 -0
  8. 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 — SimpleCNN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 3 — Backbone + ML classique
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"), show("LDA")
96
 
97
 
98
  def train_classical_callback(
99
  clf_type,
100
- svm_c, svm_kernel, svm_gamma,
101
- logreg_c, logreg_max_iter,
102
- knn_k, knn_metric,
103
- rf_n_estimators, rf_max_depth,
104
- lda_solver,
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), "kernel": svm_kernel, "gamma": svm_gamma}
118
  elif clf_type == "Régression logistique":
119
- params = {"C": float(logreg_c), "max_iter": int(logreg_max_iter)}
120
  elif clf_type == "k-NN":
121
- params = {"n_neighbors": int(knn_k), "metric": knn_metric}
122
  elif clf_type == "Forêt aléatoire":
123
- depth = int(rf_max_depth) if rf_max_depth and int(rf_max_depth) > 0 else None
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(clf_type, features_cache, class_names, model_tag, **params)
 
 
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 4 — Tester et analyser
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 trois étapes : "
199
- "**exploration des données**, **entraînement d'un CNN de zéro**, "
200
  "puis **exploitation d'un backbone préentraîné avec des algorithmes classiques**. "
201
- "L'objectif est de comprendre pourquoi l'apprentissage par transfert est si puissant, "
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 — SimpleCNN de zéro
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  # ------------------------------------------------------------------ #
245
- with gr.Tab("2. CNN entraîné de zéro"):
246
  gr.Markdown("## Entraîner un réseau convolutif sans connaissances préalables")
247
  gr.Markdown(
248
- "La première approche naturelle est de construire un réseau de neurones convolutif (CNN) "
249
- "et de l'entraîner directement sur nos données de charbons de bois. "
250
- "Ce réseau part de paramètres aléatoires : il ne sait rien des images au départ.\n\n"
 
 
 
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 3 — Backbone préentraîné + ML classique
315
  # ------------------------------------------------------------------ #
316
- with gr.Tab("3. Backbone préentraîné + ML classique"):
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, une stratégie radicalement différente "
320
- "consiste à réutiliser un réseau déjà entraîné, et à s'appuyer sur les représentations "
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 affiné sur notre jeu de données de charbons de bois**. "
329
- "Ce modèle a d'abord été préentraîné sur ImageNet (1,2 million d'images, 1 000 classes), "
330
- "puis ses poids ont été ajustés sur nos images microscopiques. "
331
- "Il a donc appris à reconnaître les structures visuelles propres aux charbons de bois.\n\n"
 
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) sont rapides à entraîner, "
336
  "interprétables, et ne nécessitent pas de GPU. "
337
- "Comparez leurs résultats avec ceux obtenus à l'étape précédente."
 
 
 
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", "LDA"],
362
  value="SVM",
363
  label="Algorithme de classification",
364
  )
365
 
366
  with gr.Column(visible=True) as svm_col:
367
- gr.Markdown("#### Paramètres SVM")
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("#### Paramètres Régression logistique")
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("#### Paramètres k-NN")
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("#### Paramètres Forêt aléatoire")
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
- with gr.Column(visible=False) as lda_col:
408
- gr.Markdown("#### Paramètres LDA")
409
- lda_solver = gr.Dropdown(
410
- choices=["svd", "lsqr", "eigen"], value="svd", label="Solveur"
411
- )
412
 
413
  ml_tag = gr.Textbox(
414
- label="Nom du modèle", placeholder="ex. svm_rbf_C1"
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 4 — Tester et analyser
427
  # ------------------------------------------------------------------ #
428
- with gr.Tab("4. Tester et analyser"):
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, lda_col],
517
  )
518
 
519
  train_classical_btn.click(
520
  fn=train_classical_callback,
521
  inputs=[
522
  clf_type,
523
- svm_c, svm_kernel, svm_gamma,
524
- logreg_c, logreg_max_iter,
525
- knn_k, knn_metric,
526
- rf_n_estimators, rf_max_depth,
527
- lda_solver,
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
- pt_path = hf_hub_download(
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=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", "lda"})
 
 
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