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

Isoler le stockage des modeles par session et corriger le role de la CV

Browse files

Chaque session de navigateur (session_hash Gradio) a maintenant son
propre sous-repertoire dans saved_models/ et saved_models_meta/, pour
qu'un(e) etudiant(e) ne voie plus les modeles entraines par un(e) autre
sur le meme Space partage.

Pour les classifieurs ML classiques, la validation croisee effectue
desormais une vraie recherche par grille (GridSearchCV) sur
l'hyperparametre expose dans l'UI au lieu de se contenter de noter la
valeur choisie a la main : elle selectionne le meilleur candidat, et
c'est ce modele-la qui est sauvegarde et evalue.

Files changed (6) hide show
  1. .gitignore +1 -0
  2. app.py +30 -18
  3. classical_ml_utils.py +75 -22
  4. config.py +23 -0
  5. predict_utils.py +8 -8
  6. train_utils.py +35 -33
.gitignore CHANGED
@@ -6,3 +6,4 @@ saved_figures/
6
  __pycache__/
7
  *.pyc
8
  .DS_Store
 
 
6
  __pycache__/
7
  *.pyc
8
  .DS_Store
9
+ test_api.py
app.py CHANGED
@@ -44,9 +44,12 @@ 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),
@@ -56,7 +59,7 @@ def train_mlp_callback(
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"],
@@ -82,9 +85,12 @@ def train_cnn_callback(
82
  dropout, fc_dim,
83
  learning_rate, weight_decay, batch_size, epochs,
84
  model_tag,
 
85
  ):
86
  try:
 
87
  result = train_cnn(
 
88
  num_conv_blocks=int(num_conv_blocks),
89
  base_filters=int(base_filters),
90
  kernel_size=int(kernel_size),
@@ -97,7 +103,7 @@ def train_cnn_callback(
97
  epochs=int(epochs),
98
  model_tag=model_tag,
99
  )
100
- models = list_saved_models()
101
  selected = result["model_name"] if result["model_name"] in models else None
102
  return (
103
  result["logs"],
@@ -142,8 +148,10 @@ def train_classical_callback(
142
  rf_n_estimators,
143
  use_cv,
144
  model_tag,
 
145
  ):
146
  try:
 
147
  features_cache = get_cached_features()
148
  if features_cache is None:
149
  return (
@@ -163,10 +171,11 @@ def train_classical_callback(
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
171
  return (
172
  result["summary"],
@@ -183,42 +192,42 @@ def train_classical_callback(
183
  # Tab 5 — Tester et analyser
184
  # ---------------------------------------------------------------------------
185
 
186
- def refresh_models_callback():
187
- models = list_saved_models()
188
  return gr.update(choices=models, value=models[0] if models else None)
189
 
190
 
191
- def get_model_info_callback(model_name):
192
  if not model_name:
193
  return {"message": "Aucun modèle sélectionné."}
194
  try:
195
- with open(model_meta_path(model_name), "r", encoding="utf-8") as f:
196
  return json.load(f)
197
  except FileNotFoundError:
198
  return {"message": "Métadonnées introuvables."}
199
 
200
 
201
  @spaces.GPU(duration=120)
202
- def evaluate_callback(model_name):
203
  try:
204
- summary, report_df, cm_df, cm_path = evaluate_saved_model(model_name)
205
  return summary, report_df, cm_df, cm_path
206
  except Exception as e:
207
  return {"Erreur": str(e)}, None, None, None
208
 
209
 
210
  @spaces.GPU(duration=60)
211
- def predict_callback(model_name, image):
212
  try:
213
- return predict_uploaded_image(model_name, image)
214
  except Exception as e:
215
  return f"Échec :\n{e}", None
216
 
217
 
218
  @spaces.GPU(duration=60)
219
- def random_test_callback(model_name):
220
  try:
221
- return test_random_sample(model_name)
222
  except Exception as e:
223
  return None, f"Échec :\n{e}", None
224
 
@@ -227,8 +236,6 @@ def random_test_callback(model_name):
227
  # UI
228
  # ---------------------------------------------------------------------------
229
 
230
- initial_models = list_saved_models()
231
-
232
  with gr.Blocks(title="Classification d'images microscopiques") as demo:
233
 
234
  gr.Markdown("# Classification d'images microscopiques de charbons de bois")
@@ -531,9 +538,10 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
531
  with gr.Row():
532
  with gr.Column():
533
  model_selector = gr.Dropdown(
534
- choices=initial_models,
535
- value=initial_models[0] if initial_models else None,
536
  label="Modèle sauvegardé",
 
537
  )
538
  refresh_btn = gr.Button("Actualiser la liste")
539
  load_info_btn = gr.Button("Afficher les informations du modèle")
@@ -662,6 +670,10 @@ with gr.Blocks(title="Classification d'images microscopiques") as demo:
662
  outputs=[random_img, random_text, random_probs],
663
  )
664
 
 
 
 
 
665
 
666
  if __name__ == "__main__":
667
  demo.launch(ssr_mode=False)
 
44
  num_layers, hidden_dim, dropout,
45
  learning_rate, weight_decay, batch_size, epochs,
46
  model_tag,
47
+ request: gr.Request,
48
  ):
49
  try:
50
+ session_id = request.session_hash
51
  result = train_mlp(
52
+ session_id=session_id,
53
  num_layers=int(num_layers),
54
  hidden_dim=int(hidden_dim),
55
  dropout=float(dropout),
 
59
  epochs=int(epochs),
60
  model_tag=model_tag,
61
  )
62
+ models = list_saved_models(session_id)
63
  selected = result["model_name"] if result["model_name"] in models else None
64
  return (
65
  result["logs"],
 
85
  dropout, fc_dim,
86
  learning_rate, weight_decay, batch_size, epochs,
87
  model_tag,
88
+ request: gr.Request,
89
  ):
90
  try:
91
+ session_id = request.session_hash
92
  result = train_cnn(
93
+ session_id=session_id,
94
  num_conv_blocks=int(num_conv_blocks),
95
  base_filters=int(base_filters),
96
  kernel_size=int(kernel_size),
 
103
  epochs=int(epochs),
104
  model_tag=model_tag,
105
  )
106
+ models = list_saved_models(session_id)
107
  selected = result["model_name"] if result["model_name"] in models else None
108
  return (
109
  result["logs"],
 
148
  rf_n_estimators,
149
  use_cv,
150
  model_tag,
151
+ request: gr.Request,
152
  ):
153
  try:
154
+ session_id = request.session_hash
155
  features_cache = get_cached_features()
156
  if features_cache is None:
157
  return (
 
171
 
172
  class_names = get_class_names()
173
  result = train_classical_model(
174
+ clf_type, features_cache, class_names, session_id,
175
+ model_tag=model_tag, use_cv=bool(use_cv), **params
176
  )
177
 
178
+ models = list_saved_models(session_id)
179
  selected = result["model_name"] if result["model_name"] in models else None
180
  return (
181
  result["summary"],
 
192
  # Tab 5 — Tester et analyser
193
  # ---------------------------------------------------------------------------
194
 
195
+ def refresh_models_callback(request: gr.Request):
196
+ models = list_saved_models(request.session_hash)
197
  return gr.update(choices=models, value=models[0] if models else None)
198
 
199
 
200
+ def get_model_info_callback(model_name, request: gr.Request):
201
  if not model_name:
202
  return {"message": "Aucun modèle sélectionné."}
203
  try:
204
+ with open(model_meta_path(model_name, request.session_hash), "r", encoding="utf-8") as f:
205
  return json.load(f)
206
  except FileNotFoundError:
207
  return {"message": "Métadonnées introuvables."}
208
 
209
 
210
  @spaces.GPU(duration=120)
211
+ def evaluate_callback(model_name, request: gr.Request):
212
  try:
213
+ summary, report_df, cm_df, cm_path = evaluate_saved_model(model_name, request.session_hash)
214
  return summary, report_df, cm_df, cm_path
215
  except Exception as e:
216
  return {"Erreur": str(e)}, None, None, None
217
 
218
 
219
  @spaces.GPU(duration=60)
220
+ def predict_callback(model_name, image, request: gr.Request):
221
  try:
222
+ return predict_uploaded_image(model_name, image, request.session_hash)
223
  except Exception as e:
224
  return f"Échec :\n{e}", None
225
 
226
 
227
  @spaces.GPU(duration=60)
228
+ def random_test_callback(model_name, request: gr.Request):
229
  try:
230
+ return test_random_sample(model_name, request.session_hash)
231
  except Exception as e:
232
  return None, f"Échec :\n{e}", None
233
 
 
236
  # UI
237
  # ---------------------------------------------------------------------------
238
 
 
 
239
  with gr.Blocks(title="Classification d'images microscopiques") as demo:
240
 
241
  gr.Markdown("# Classification d'images microscopiques de charbons de bois")
 
538
  with gr.Row():
539
  with gr.Column():
540
  model_selector = gr.Dropdown(
541
+ choices=[],
542
+ value=None,
543
  label="Modèle sauvegardé",
544
+ info="Liste propre à votre session — les modèles des autres étudiant·e·s ne sont pas visibles ici.",
545
  )
546
  refresh_btn = gr.Button("Actualiser la liste")
547
  load_info_btn = gr.Button("Afficher les informations du modèle")
 
670
  outputs=[random_img, random_text, random_probs],
671
  )
672
 
673
+ # Peuple la liste des modèles au chargement de la page, à partir de la
674
+ # session du navigateur qui vient de se connecter (cf. refresh_models_callback).
675
+ demo.load(fn=refresh_models_callback, inputs=None, outputs=model_selector)
676
+
677
 
678
  if __name__ == "__main__":
679
  demo.launch(ssr_mode=False)
classical_ml_utils.py CHANGED
@@ -7,13 +7,13 @@ from typing import List
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 = {
@@ -23,13 +23,25 @@ CLF_TYPE_MAP = {
23
  "Forêt aléatoire": "rf",
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- def classifier_path(model_name: str) -> str:
28
- return os.path.join(MODEL_DIR, f"{model_name}.joblib")
29
 
30
 
31
- def meta_path(model_name: str) -> str:
32
- return os.path.join(META_DIR, f"{model_name}.json")
33
 
34
 
35
  def build_pipeline(clf_type: str, **params) -> Pipeline:
@@ -67,27 +79,58 @@ def build_pipeline(clf_type: str, **params) -> Pipeline:
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,
@@ -97,10 +140,20 @@ def train_classical_model(
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
 
105
  y_pred = pipeline.predict(X_test)
106
  metrics = compute_classification_metrics(y_test.tolist(), y_pred.tolist(), class_names)
@@ -109,7 +162,7 @@ def train_classical_model(
109
  safe_tag = model_tag.strip().replace(" ", "_") if model_tag.strip() else CLF_TYPE_MAP.get(clf_type, "clf")
110
  model_name = f"{safe_tag}_{timestamp}"
111
 
112
- joblib.dump(pipeline, classifier_path(model_name))
113
  cm_path = save_confusion_matrix_figure(metrics["confusion_matrix"], model_name)
114
 
115
  config_dict = {
@@ -129,7 +182,7 @@ def train_classical_model(
129
  **(cv_metrics or {}),
130
  }
131
 
132
- with open(meta_path(model_name), "w", encoding="utf-8") as f:
133
  json.dump(
134
  {
135
  "model_name": model_name,
@@ -151,8 +204,8 @@ def train_classical_model(
151
  }
152
 
153
 
154
- def load_classical_pipeline(model_name: str) -> Pipeline:
155
- path = classifier_path(model_name)
156
  if not os.path.exists(path):
157
  raise FileNotFoundError(f"Classifieur introuvable : {model_name}")
158
  return joblib.load(path)
 
7
  import joblib
8
  from sklearn.ensemble import RandomForestClassifier
9
  from sklearn.linear_model import LogisticRegression
10
+ from sklearn.model_selection import GridSearchCV, StratifiedKFold
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 CV_FOLDS, session_model_dir, session_meta_dir
17
  from metrics_utils import compute_classification_metrics, save_confusion_matrix_figure
18
 
19
  CLF_TYPE_MAP = {
 
23
  "Forêt aléatoire": "rf",
24
  }
25
 
26
+ # Grille de recherche par algorithme : le SEUL hyperparamètre exposé dans
27
+ # l'UI est aussi celui balayé par la validation croisée — c'est ce balayage
28
+ # (et non le score de stabilité d'une valeur choisie à la main) qui constitue
29
+ # le véritable rôle pédagogique de la CV : sélectionner le meilleur
30
+ # hyperparamètre plutôt que de simplement le deviner.
31
+ PARAM_GRIDS = {
32
+ "svm": ("C", [0.01, 0.1, 1.0, 10.0, 100.0]),
33
+ "logreg": ("C", [0.01, 0.1, 1.0, 10.0, 100.0]),
34
+ "knn": ("n_neighbors", [1, 3, 5, 7, 9, 15]),
35
+ "rf": ("n_estimators", [50, 100, 200, 300]),
36
+ }
37
+
38
 
39
+ def classifier_path(model_name: str, session_id: str) -> str:
40
+ return os.path.join(session_model_dir(session_id), f"{model_name}.joblib")
41
 
42
 
43
+ def meta_path(model_name: str, session_id: str) -> str:
44
+ return os.path.join(session_meta_dir(session_id), f"{model_name}.json")
45
 
46
 
47
  def build_pipeline(clf_type: str, **params) -> Pipeline:
 
79
  return Pipeline([("scaler", StandardScaler()), ("clf", clf)])
80
 
81
 
82
+ def _grid_search_cv(clf_type: str, X_train, y_train, manual_value, **other_params):
83
+ """Recherche par grille en validation croisée : le véritable rôle de la CV
84
+ n'est pas de noter la valeur choisie à la main, mais de comparer plusieurs
85
+ valeurs candidates et de sélectionner celle qui généralise le mieux.
86
+ La valeur choisie manuellement par l'étudiant·e est incluse dans la
87
+ grille pour qu'il/elle puisse comparer son choix au choix retenu par CV.
88
+ """
89
+ key = CLF_TYPE_MAP.get(clf_type, clf_type)
90
+ hp_name, default_grid = PARAM_GRIDS[key]
91
+ candidates = sorted(set(default_grid) | ({manual_value} if manual_value is not None else set()))
92
+
93
  min_class_count = min(Counter(y_train.tolist()).values())
94
  folds = max(2, min(CV_FOLDS, min_class_count))
 
 
95
  skf = StratifiedKFold(n_splits=folds, shuffle=True, random_state=42)
 
96
 
97
+ pipeline = build_pipeline(clf_type, **{hp_name: candidates[0], **other_params})
98
+ param_grid = {f"clf__{hp_name}": candidates}
99
+
100
+ search = GridSearchCV(
101
+ pipeline, param_grid=param_grid, cv=skf, scoring="f1_macro", refit=True, n_jobs=-1
102
+ )
103
+ search.fit(X_train, y_train)
104
+
105
+ grid_results = []
106
+ for candidate_params, mean_score, std_score in zip(
107
+ search.cv_results_["params"],
108
+ search.cv_results_["mean_test_score"],
109
+ search.cv_results_["std_test_score"],
110
+ ):
111
+ grid_results.append({
112
+ hp_name: candidate_params[f"clf__{hp_name}"],
113
+ "cv_f1_macro_mean": round(float(mean_score), 4),
114
+ "cv_f1_macro_std": round(float(std_score), 4),
115
+ })
116
+ grid_results.sort(key=lambda r: r[hp_name])
117
+
118
+ cv_metrics = {
119
  "cv_folds": folds,
120
+ "hyperparameter_name": hp_name,
121
+ "manual_value": manual_value,
122
+ "cv_best_value": search.best_params_[f"clf__{hp_name}"],
123
+ "cv_best_f1_macro": round(float(search.best_score_), 4),
124
+ "cv_grid_results": grid_results,
125
  }
126
+ return cv_metrics, search.best_estimator_
127
 
128
 
129
  def train_classical_model(
130
  clf_type: str,
131
  features_cache: dict,
132
  class_names: List[str],
133
+ session_id: str,
134
  model_tag: str = "",
135
  use_cv: bool = False,
136
  **params,
 
140
  X_test = features_cache["test"]["X"]
141
  y_test = features_cache["test"]["y"]
142
 
143
+ key = CLF_TYPE_MAP.get(clf_type, clf_type)
144
 
145
+ if use_cv:
146
+ hp_name, _ = PARAM_GRIDS[key]
147
+ manual_value = params.get(hp_name)
148
+ other_params = {k: v for k, v in params.items() if k != hp_name}
149
+ cv_metrics, pipeline = _grid_search_cv(clf_type, X_train, y_train, manual_value, **other_params)
150
+ # Le modèle sauvegardé utilise l'hyperparamètre sélectionné par CV,
151
+ # pas nécessairement celui choisi à la main dans l'UI.
152
+ params = {**params, hp_name: cv_metrics["cv_best_value"]}
153
+ else:
154
+ cv_metrics = None
155
+ pipeline = build_pipeline(clf_type, **params)
156
+ pipeline.fit(X_train, y_train)
157
 
158
  y_pred = pipeline.predict(X_test)
159
  metrics = compute_classification_metrics(y_test.tolist(), y_pred.tolist(), class_names)
 
162
  safe_tag = model_tag.strip().replace(" ", "_") if model_tag.strip() else CLF_TYPE_MAP.get(clf_type, "clf")
163
  model_name = f"{safe_tag}_{timestamp}"
164
 
165
+ joblib.dump(pipeline, classifier_path(model_name, session_id))
166
  cm_path = save_confusion_matrix_figure(metrics["confusion_matrix"], model_name)
167
 
168
  config_dict = {
 
182
  **(cv_metrics or {}),
183
  }
184
 
185
+ with open(meta_path(model_name, session_id), "w", encoding="utf-8") as f:
186
  json.dump(
187
  {
188
  "model_name": model_name,
 
204
  }
205
 
206
 
207
+ def load_classical_pipeline(model_name: str, session_id: str) -> Pipeline:
208
+ path = classifier_path(model_name, session_id)
209
  if not os.path.exists(path):
210
  raise FileNotFoundError(f"Classifieur introuvable : {model_name}")
211
  return joblib.load(path)
config.py CHANGED
@@ -13,6 +13,29 @@ os.makedirs(FIGURE_DIR, exist_ok=True)
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
17
  RANDOM_SEED = 42
18
 
 
13
  HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "CircleStar/charcoal-microscopy")
14
  HF_TOKEN = os.environ.get("HF_TOKEN")
15
 
16
+
17
+ def _sanitize_session_id(session_id: str) -> str:
18
+ """Un session_hash Gradio est déjà un identifiant sûr, mais on filtre par
19
+ prudence pour ne jamais laisser un caractère de traversée de chemin
20
+ (../) atteindre os.path.join."""
21
+ safe = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")
22
+ return safe or "default"
23
+
24
+
25
+ def session_model_dir(session_id: str) -> str:
26
+ """Répertoire des poids/pipelines pour UNE session de navigateur — isole
27
+ les modèles entraînés par un·e étudiant·e de ceux des autres, qui
28
+ partagent pourtant le même Space Gradio."""
29
+ d = os.path.join(MODEL_DIR, _sanitize_session_id(session_id))
30
+ os.makedirs(d, exist_ok=True)
31
+ return d
32
+
33
+
34
+ def session_meta_dir(session_id: str) -> str:
35
+ d = os.path.join(META_DIR, _sanitize_session_id(session_id))
36
+ os.makedirs(d, exist_ok=True)
37
+ return d
38
+
39
  IMAGE_SIZE = 224
40
  RANDOM_SEED = 42
41
 
predict_utils.py CHANGED
@@ -19,25 +19,25 @@ def _extract_feature(image: Image.Image, device: torch.device) -> np.ndarray:
19
  return feat.cpu().numpy()
20
 
21
 
22
- def predict_uploaded_image(model_name: str, image: Image.Image):
23
  if not model_name:
24
  return "Veuillez sélectionner un modèle.", None
25
  if image is None:
26
  return "Veuillez importer une image.", None
27
 
28
- meta = _load_meta(model_name)
29
  model_type = meta["config"].get("model_type", "cnn")
30
  class_names = meta["config"]["class_names"]
31
  device = get_runtime_device()
32
 
33
  if model_type in CLASSICAL_MODEL_TYPES:
34
  from classical_ml_utils import load_classical_pipeline
35
- pipeline = load_classical_pipeline(model_name)
36
  feat = _extract_feature(image, device)
37
  probs = pipeline.predict_proba(feat)[0].tolist()
38
  pred_idx = int(np.argmax(probs))
39
  else:
40
- model, _ = load_model(model_name, device)
41
  tensor = get_eval_transform()(image.convert("RGB")).unsqueeze(0).to(device)
42
  with torch.no_grad():
43
  logits = model(tensor)
@@ -55,11 +55,11 @@ def predict_uploaded_image(model_name: str, image: Image.Image):
55
  return result_text, prob_dict
56
 
57
 
58
- def test_random_sample(model_name: str):
59
  if not model_name:
60
  return None, "Veuillez sélectionner un modèle.", None
61
 
62
- meta = _load_meta(model_name)
63
  model_type = meta["config"].get("model_type", "cnn")
64
  class_names = get_class_names()
65
  device = get_runtime_device()
@@ -77,12 +77,12 @@ def test_random_sample(model_name: str):
77
 
78
  if model_type in CLASSICAL_MODEL_TYPES:
79
  from classical_ml_utils import load_classical_pipeline
80
- pipeline = load_classical_pipeline(model_name)
81
  feat = _extract_feature(image, device)
82
  probs = pipeline.predict_proba(feat)[0].tolist()
83
  pred_idx = int(np.argmax(probs))
84
  else:
85
- model, _ = load_model(model_name, device)
86
  tensor = get_eval_transform()(image).unsqueeze(0).to(device)
87
  with torch.no_grad():
88
  logits = model(tensor)
 
19
  return feat.cpu().numpy()
20
 
21
 
22
+ def predict_uploaded_image(model_name: str, image: Image.Image, session_id: str):
23
  if not model_name:
24
  return "Veuillez sélectionner un modèle.", None
25
  if image is None:
26
  return "Veuillez importer une image.", None
27
 
28
+ meta = _load_meta(model_name, session_id)
29
  model_type = meta["config"].get("model_type", "cnn")
30
  class_names = meta["config"]["class_names"]
31
  device = get_runtime_device()
32
 
33
  if model_type in CLASSICAL_MODEL_TYPES:
34
  from classical_ml_utils import load_classical_pipeline
35
+ pipeline = load_classical_pipeline(model_name, session_id)
36
  feat = _extract_feature(image, device)
37
  probs = pipeline.predict_proba(feat)[0].tolist()
38
  pred_idx = int(np.argmax(probs))
39
  else:
40
+ model, _ = load_model(model_name, device, session_id)
41
  tensor = get_eval_transform()(image.convert("RGB")).unsqueeze(0).to(device)
42
  with torch.no_grad():
43
  logits = model(tensor)
 
55
  return result_text, prob_dict
56
 
57
 
58
+ def test_random_sample(model_name: str, session_id: str):
59
  if not model_name:
60
  return None, "Veuillez sélectionner un modèle.", None
61
 
62
+ meta = _load_meta(model_name, session_id)
63
  model_type = meta["config"].get("model_type", "cnn")
64
  class_names = get_class_names()
65
  device = get_runtime_device()
 
77
 
78
  if model_type in CLASSICAL_MODEL_TYPES:
79
  from classical_ml_utils import load_classical_pipeline
80
+ pipeline = load_classical_pipeline(model_name, session_id)
81
  feat = _extract_feature(image, device)
82
  probs = pipeline.predict_proba(feat)[0].tolist()
83
  pred_idx = int(np.argmax(probs))
84
  else:
85
+ model, _ = load_model(model_name, device, session_id)
86
  tensor = get_eval_transform()(image).unsqueeze(0).to(device)
87
  with torch.no_grad():
88
  logits = model(tensor)
train_utils.py CHANGED
@@ -8,31 +8,30 @@ 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, 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
  # ---------------------------------------------------------------------------
18
- # Path helpers
 
 
19
  # ---------------------------------------------------------------------------
20
 
21
- def model_weight_path(model_name: str) -> str:
22
- return os.path.join(MODEL_DIR, f"{model_name}.pt")
23
 
24
 
25
- def classifier_weight_path(model_name: str) -> str:
26
- return os.path.join(MODEL_DIR, f"{model_name}.joblib")
27
 
28
 
29
- def model_meta_path(model_name: str) -> str:
30
- return os.path.join(META_DIR, f"{model_name}.json")
31
-
32
-
33
- def list_saved_models() -> List[str]:
34
  names = []
35
- for fn in os.listdir(META_DIR):
36
  if fn.endswith(".json"):
37
  names.append(fn[:-5])
38
  return sorted(names, reverse=True)
@@ -46,15 +45,15 @@ def get_runtime_device() -> torch.device:
46
  # Save / load
47
  # ---------------------------------------------------------------------------
48
 
49
- def save_model(model: nn.Module, model_name: str, config: dict, training_summary: dict):
50
  if config["model_type"] == "fc_head":
51
  state_dict = {k: v.detach().cpu() for k, v in model.classifier.state_dict().items()}
52
  else:
53
  state_dict = {k: v.detach().cpu() for k, v in model.state_dict().items()}
54
 
55
- torch.save(state_dict, model_weight_path(model_name))
56
 
57
- with open(model_meta_path(model_name), "w", encoding="utf-8") as f:
58
  json.dump(
59
  {
60
  "model_name": model_name,
@@ -68,16 +67,16 @@ def save_model(model: nn.Module, model_name: str, config: dict, training_summary
68
  )
69
 
70
 
71
- def _load_meta(model_name: str) -> dict:
72
- path = model_meta_path(model_name)
73
  if not os.path.exists(path):
74
  raise FileNotFoundError(f"Métadonnées introuvables : {model_name}")
75
  with open(path, "r", encoding="utf-8") as f:
76
  return json.load(f)
77
 
78
 
79
- def load_model(model_name: str, device: torch.device) -> Tuple[nn.Module, dict]:
80
- meta = _load_meta(model_name)
81
  cfg = meta["config"]
82
  model_type = cfg.get("model_type", "cnn")
83
 
@@ -86,7 +85,7 @@ def load_model(model_name: str, device: torch.device) -> Tuple[nn.Module, dict]:
86
  backbone = load_backbone(device)
87
  model = BackboneWithFC(backbone, cfg["num_classes"], cfg.get("dropout", 0.4), cfg.get("fc_dim", 256))
88
  model.classifier.load_state_dict(
89
- torch.load(model_weight_path(model_name), map_location="cpu")
90
  )
91
 
92
  elif model_type == "cnn":
@@ -99,7 +98,7 @@ def load_model(model_name: str, device: torch.device) -> Tuple[nn.Module, dict]:
99
  dropout=cfg.get("dropout", 0.4),
100
  fc_dim=cfg.get("fc_dim", 256),
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(
@@ -109,7 +108,7 @@ def load_model(model_name: str, device: torch.device) -> Tuple[nn.Module, dict]:
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.")
@@ -207,6 +206,7 @@ def _training_loop(model, train_loader, val_loader, criterion, optimizer, schedu
207
  # ---------------------------------------------------------------------------
208
 
209
  def train_fc_head(
 
210
  dropout: float = 0.4,
211
  fc_dim: int = 256,
212
  learning_rate: float = 1e-4,
@@ -281,7 +281,7 @@ def train_fc_head(
281
  "trainable_params": trainable_params,
282
  }
283
 
284
- save_model(model, model_name, config, training_summary)
285
 
286
  logs += [
287
  "",
@@ -310,6 +310,7 @@ def train_fc_head(
310
  # ---------------------------------------------------------------------------
311
 
312
  def train_cnn(
 
313
  num_conv_blocks: int = 3,
314
  base_filters: int = 32,
315
  kernel_size: int = 3,
@@ -399,7 +400,7 @@ def train_cnn(
399
  "trainable_params": trainable_params,
400
  }
401
 
402
- save_model(model, model_name, config, training_summary)
403
 
404
  logs += [
405
  "",
@@ -429,6 +430,7 @@ def train_cnn(
429
  # ---------------------------------------------------------------------------
430
 
431
  def train_mlp(
 
432
  num_layers: int = 2,
433
  hidden_dim: int = 256,
434
  dropout: float = 0.4,
@@ -512,7 +514,7 @@ def train_mlp(
512
  "trainable_params": trainable_params,
513
  }
514
 
515
- save_model(model, model_name, config, training_summary)
516
 
517
  logs += [
518
  "",
@@ -541,22 +543,22 @@ def train_mlp(
541
  # Evaluate any saved model
542
  # ---------------------------------------------------------------------------
543
 
544
- def evaluate_saved_model(model_name: str):
545
  if not model_name:
546
  raise ValueError("Aucun modèle sélectionné.")
547
 
548
- meta = _load_meta(model_name)
549
  model_type = meta["config"].get("model_type", "cnn")
550
 
551
  if model_type in CLASSICAL_MODEL_TYPES:
552
- return _evaluate_classical(model_name, meta)
553
  else:
554
- return _evaluate_neural(model_name, meta)
555
 
556
 
557
- def _evaluate_neural(model_name: str, meta: dict):
558
  device = get_runtime_device()
559
- model, meta = load_model(model_name, device)
560
 
561
  batch_size = int(meta["config"].get("batch_size", 16))
562
  _, _, test_loader, class_names = make_loaders(batch_size)
@@ -582,7 +584,7 @@ def _evaluate_neural(model_name: str, meta: dict):
582
  )
583
 
584
 
585
- def _evaluate_classical(model_name: str, meta: dict):
586
  from backbone_utils import get_cached_features, extract_all_features
587
  from classical_ml_utils import load_classical_pipeline
588
 
@@ -591,7 +593,7 @@ def _evaluate_classical(model_name: str, meta: dict):
591
  features_cache, _, _ = extract_all_features()
592
 
593
  class_names = meta["config"]["class_names"]
594
- pipeline = load_classical_pipeline(model_name)
595
 
596
  X_test = features_cache["test"]["X"]
597
  y_test = features_cache["test"]["y"]
 
8
  import torch.nn as nn
9
  import torch.optim as optim
10
 
11
+ from config import DATASET_DISPLAY_NAME, CLASSICAL_MODEL_TYPES, IMAGE_SIZE, session_model_dir, session_meta_dir
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
  # ---------------------------------------------------------------------------
18
+ # Path helpers — tout est scopé par session_id (= session_hash Gradio, un par
19
+ # navigateur/onglet) pour qu'un·e étudiant·e ne voie jamais les modèles d'un·e
20
+ # autre alors que tous partagent le même Space.
21
  # ---------------------------------------------------------------------------
22
 
23
+ def model_weight_path(model_name: str, session_id: str) -> str:
24
+ return os.path.join(session_model_dir(session_id), f"{model_name}.pt")
25
 
26
 
27
+ def model_meta_path(model_name: str, session_id: str) -> str:
28
+ return os.path.join(session_meta_dir(session_id), f"{model_name}.json")
29
 
30
 
31
+ def list_saved_models(session_id: str) -> List[str]:
32
+ meta_dir = session_meta_dir(session_id)
 
 
 
33
  names = []
34
+ for fn in os.listdir(meta_dir):
35
  if fn.endswith(".json"):
36
  names.append(fn[:-5])
37
  return sorted(names, reverse=True)
 
45
  # Save / load
46
  # ---------------------------------------------------------------------------
47
 
48
+ def save_model(model: nn.Module, model_name: str, config: dict, training_summary: dict, session_id: str):
49
  if config["model_type"] == "fc_head":
50
  state_dict = {k: v.detach().cpu() for k, v in model.classifier.state_dict().items()}
51
  else:
52
  state_dict = {k: v.detach().cpu() for k, v in model.state_dict().items()}
53
 
54
+ torch.save(state_dict, model_weight_path(model_name, session_id))
55
 
56
+ with open(model_meta_path(model_name, session_id), "w", encoding="utf-8") as f:
57
  json.dump(
58
  {
59
  "model_name": model_name,
 
67
  )
68
 
69
 
70
+ def _load_meta(model_name: str, session_id: str) -> dict:
71
+ path = model_meta_path(model_name, session_id)
72
  if not os.path.exists(path):
73
  raise FileNotFoundError(f"Métadonnées introuvables : {model_name}")
74
  with open(path, "r", encoding="utf-8") as f:
75
  return json.load(f)
76
 
77
 
78
+ def load_model(model_name: str, device: torch.device, session_id: str) -> Tuple[nn.Module, dict]:
79
+ meta = _load_meta(model_name, session_id)
80
  cfg = meta["config"]
81
  model_type = cfg.get("model_type", "cnn")
82
 
 
85
  backbone = load_backbone(device)
86
  model = BackboneWithFC(backbone, cfg["num_classes"], cfg.get("dropout", 0.4), cfg.get("fc_dim", 256))
87
  model.classifier.load_state_dict(
88
+ torch.load(model_weight_path(model_name, session_id), map_location="cpu")
89
  )
90
 
91
  elif model_type == "cnn":
 
98
  dropout=cfg.get("dropout", 0.4),
99
  fc_dim=cfg.get("fc_dim", 256),
100
  )
101
+ model.load_state_dict(torch.load(model_weight_path(model_name, session_id), map_location="cpu"))
102
 
103
  elif model_type == "mlp":
104
  model = MLP(
 
108
  hidden_dim=cfg.get("hidden_dim", 256),
109
  dropout=cfg.get("dropout", 0.4),
110
  )
111
+ model.load_state_dict(torch.load(model_weight_path(model_name, session_id), map_location="cpu"))
112
 
113
  else:
114
  raise ValueError(f"load_model n'accepte pas le type '{model_type}'. Utilisez load_classical_pipeline pour les modèles ML classiques.")
 
206
  # ---------------------------------------------------------------------------
207
 
208
  def train_fc_head(
209
+ session_id: str,
210
  dropout: float = 0.4,
211
  fc_dim: int = 256,
212
  learning_rate: float = 1e-4,
 
281
  "trainable_params": trainable_params,
282
  }
283
 
284
+ save_model(model, model_name, config, training_summary, session_id)
285
 
286
  logs += [
287
  "",
 
310
  # ---------------------------------------------------------------------------
311
 
312
  def train_cnn(
313
+ session_id: str,
314
  num_conv_blocks: int = 3,
315
  base_filters: int = 32,
316
  kernel_size: int = 3,
 
400
  "trainable_params": trainable_params,
401
  }
402
 
403
+ save_model(model, model_name, config, training_summary, session_id)
404
 
405
  logs += [
406
  "",
 
430
  # ---------------------------------------------------------------------------
431
 
432
  def train_mlp(
433
+ session_id: str,
434
  num_layers: int = 2,
435
  hidden_dim: int = 256,
436
  dropout: float = 0.4,
 
514
  "trainable_params": trainable_params,
515
  }
516
 
517
+ save_model(model, model_name, config, training_summary, session_id)
518
 
519
  logs += [
520
  "",
 
543
  # Evaluate any saved model
544
  # ---------------------------------------------------------------------------
545
 
546
+ def evaluate_saved_model(model_name: str, session_id: str):
547
  if not model_name:
548
  raise ValueError("Aucun modèle sélectionné.")
549
 
550
+ meta = _load_meta(model_name, session_id)
551
  model_type = meta["config"].get("model_type", "cnn")
552
 
553
  if model_type in CLASSICAL_MODEL_TYPES:
554
+ return _evaluate_classical(model_name, meta, session_id)
555
  else:
556
+ return _evaluate_neural(model_name, meta, session_id)
557
 
558
 
559
+ def _evaluate_neural(model_name: str, meta: dict, session_id: str):
560
  device = get_runtime_device()
561
+ model, meta = load_model(model_name, device, session_id)
562
 
563
  batch_size = int(meta["config"].get("batch_size", 16))
564
  _, _, test_loader, class_names = make_loaders(batch_size)
 
584
  )
585
 
586
 
587
+ def _evaluate_classical(model_name: str, meta: dict, session_id: str):
588
  from backbone_utils import get_cached_features, extract_all_features
589
  from classical_ml_utils import load_classical_pipeline
590
 
 
593
  features_cache, _, _ = extract_all_features()
594
 
595
  class_names = meta["config"]["class_names"]
596
+ pipeline = load_classical_pipeline(model_name, session_id)
597
 
598
  X_test = features_cache["test"]["X"]
599
  y_test = features_cache["test"]["y"]