{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":46105,"databundleVersionId":5087314},{"sourceType":"kernelVersion","sourceId":320708568}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport json, os, time, warnings\nwarnings.filterwarnings('ignore')\n \nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.preprocessing import LabelEncoder\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import accuracy_score, f1_score\nfrom tqdm import tqdm\nimport matplotlib.pyplot as plt\n \nDATA_PATH   = '/kaggle/input/competitions/asl-signs'\nOUTPUT_PATH = '/kaggle/working'\nDEVICE      = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nSEED        = 42\ntorch.manual_seed(SEED)\nnp.random.seed(SEED)\n \n# ── Nouveaux paramètres ───────────────────────────────────────\n# On garde mains + pose MAIS on les encode différemment\n# Mains : 21 pts × 2 (LH + RH) = 42 pts × 3 coords = 126\n# Pose  : 11 pts (haut du corps) × 3 coords = 33\n# Total : 159 features par frame\nPOSE_UPPER  = list(range(11))   # 11 premiers pts de pose (épaules, coudes, poignets)\nN_LH        = 21\nN_RH        = 21\nN_POSE      = len(POSE_UPPER)   # 11\nN_FEATURES  = (N_LH + N_RH + N_POSE) * 3  # 159\nN_FRAMES    = 64\nN_CLASSES   = 250\nBATCH_SIZE  = 64\n \nprint(f\"✅ Nouveau preprocessing :\")\nprint(f\"   N_FEATURES : {N_FEATURES}  (LH21 + RH21 + Pose11) × 3 coords\")\nprint(f\"   N_FRAMES   : {N_FRAMES}\")\nprint(f\"   Device     : {DEVICE}\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-05-20T12:01:00.423782Z","iopub.execute_input":"2026-05-20T12:01:00.424707Z","iopub.status.idle":"2026-05-20T12:01:00.433365Z","shell.execute_reply.started":"2026-05-20T12:01:00.424670Z","shell.execute_reply":"2026-05-20T12:01:00.432657Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def preprocess_v3(parquet_path, n_frames=N_FRAMES):\n    \"\"\"\n    Preprocessing relatif au poignet droit.\n \n    Au lieu de normaliser min-max (qui détruit les distances relatives),\n    on centre chaque frame sur le poignet droit et on divise par\n    la distance inter-épaules (invariant à la taille/distance caméra).\n \n    Si main droite absente → coordonnées zéro pour cette frame.\n    Si pose absente → pas de centrage (fallback à 0).\n    \"\"\"\n    try:\n        df = pd.read_parquet(parquet_path)\n    except Exception:\n        return np.zeros((n_frames, N_FEATURES), dtype=np.float32)\n \n    frames = sorted(df['frame'].unique())\n    result = []\n \n    for fidx in frames:\n        fd = df[df['frame'] == fidx]\n        frame_vec = []\n \n        # ── Main gauche ──────────────────────────────────────\n        lh = fd[fd['type'] == 'left_hand'].sort_values('landmark_index')\n        if len(lh) == N_LH:\n            vals = lh[['x','y','z']].values.astype(np.float32)\n            vals = np.nan_to_num(vals, nan=0.0)\n        else:\n            vals = np.zeros((N_LH, 3), dtype=np.float32)\n        frame_vec.append(vals.flatten())\n \n        # ── Main droite ──────────────────────────────────────\n        rh = fd[fd['type'] == 'right_hand'].sort_values('landmark_index')\n        if len(rh) == N_RH:\n            vals = rh[['x','y','z']].values.astype(np.float32)\n            vals = np.nan_to_num(vals, nan=0.0)\n        else:\n            vals = np.zeros((N_RH, 3), dtype=np.float32)\n        frame_vec.append(vals.flatten())\n \n        # ── Pose (haut du corps seulement) ───────────────────\n        pose = fd[fd['type'] == 'pose'].sort_values('landmark_index')\n        pose = pose[pose['landmark_index'].isin(POSE_UPPER)]\n        if len(pose) == N_POSE:\n            vals = pose[['x','y','z']].values.astype(np.float32)\n            vals = np.nan_to_num(vals, nan=0.0)\n        else:\n            vals = np.zeros((N_POSE, 3), dtype=np.float32)\n        frame_vec.append(vals.flatten())\n \n        result.append(np.concatenate(frame_vec))\n \n    if not result:\n        return np.zeros((n_frames, N_FEATURES), dtype=np.float32)\n \n    seq = np.array(result, dtype=np.float32)  # (T, 159)\n \n    # ── Normalisation relative ────────────────────────────────\n    # Centrer sur le poignet droit (pt 0 de RH = features [63:66])\n    # et normaliser par l'écart-type de la séquence\n    # Seulement pour les frames où la main est détectée\n    non_zero_mask = seq[:, 63:66].sum(axis=1) != 0  # frames avec RH\n \n    if non_zero_mask.sum() > 0:\n        # Centre = moyenne du poignet droit sur les frames détectées\n        center = seq[non_zero_mask, 63:66].mean(axis=0)  # (3,)\n        # Répéter le centre pour chaque feature group (LH, RH, Pose)\n        center_full = np.tile(center, N_LH + N_RH + N_POSE)  # (159,)\n        seq = seq - center_full\n \n        # Normaliser par l'std global de la séquence\n        std = seq[non_zero_mask].std() + 1e-8\n        seq = seq / std\n \n    # Clip pour éviter les outliers extrêmes\n    seq = np.clip(seq, -5, 5)\n \n    # ── Padding / troncature ─────────────────────────────────\n    T = len(seq)\n    if T >= n_frames:\n        seq = seq[:n_frames]\n    else:\n        pad = np.zeros((n_frames - T, N_FEATURES), dtype=np.float32)\n        seq = np.vstack([seq, pad])\n \n    return seq.astype(np.float32)  # (64, 159)\n \n \n# Test\ndf_meta   = pd.read_csv(f'{DATA_PATH}/train.csv')\ntest_path = f\"{DATA_PATH}/{df_meta.iloc[0]['path']}\"\ntest_seq  = preprocess_v3(test_path)\n \nprint(f\"\\n✅ Test nouveau preprocessing :\")\nprint(f\"   Shape  : {test_seq.shape}  ← doit être (64, {N_FEATURES})\")\nprint(f\"   Min    : {test_seq.min():.3f}\")\nprint(f\"   Max    : {test_seq.max():.3f}\")\nprint(f\"   NaN    : {np.isnan(test_seq).sum()}\")\nprint(f\"   Frames non-nulles : {(test_seq.sum(1) != 0).sum()}/64\")\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-20T12:02:11.449354Z","iopub.execute_input":"2026-05-20T12:02:11.450217Z","iopub.status.idle":"2026-05-20T12:02:11.962153Z","shell.execute_reply.started":"2026-05-20T12:02:11.450181Z","shell.execute_reply":"2026-05-20T12:02:11.961189Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"le = LabelEncoder()\ndf_meta['label'] = le.fit_transform(df_meta['sign'])\n \nlabel_map = {\n    'index_to_sign': {str(i): cls for i, cls in enumerate(le.classes_)},\n    'sign_to_index': {cls: int(i) for i, cls in enumerate(le.classes_)},\n    'num_classes': len(le.classes_)\n}\nwith open(f'{OUTPUT_PATH}/label_map.json', 'w') as f:\n    json.dump(label_map, f, indent=2)\n \n# Même split stratifié\ntrain_df, temp_df = train_test_split(\n    df_meta, test_size=0.30, stratify=df_meta['label'], random_state=SEED\n)\nval_df, test_df = train_test_split(\n    temp_df, test_size=0.50, stratify=temp_df['label'], random_state=SEED\n)\ntrain_df = train_df.reset_index(drop=True)\nval_df   = val_df.reset_index(drop=True)\ntest_df  = test_df.reset_index(drop=True)\n \nprint(f\"Split : Train={len(train_df)} · Val={len(val_df)} · Test={len(test_df)}\")\n \n \ndef process_split(split_df, name):\n    # Reprendre si déjà fait\n    sx = f'{OUTPUT_PATH}/X_{name}_v3.npy'\n    sy = f'{OUTPUT_PATH}/y_{name}_v3.npy'\n    if os.path.exists(sx):\n        print(f\"✅ {name} déjà disponible\")\n        return np.load(sx), np.load(sy)\n \n    X_list, y_list = [], []\n    for _, row in tqdm(split_df.iterrows(), total=len(split_df), desc=name):\n        X_list.append(preprocess_v3(f\"{DATA_PATH}/{row['path']}\"))\n        y_list.append(row['label'])\n \n    X = np.array(X_list, dtype=np.float32)\n    y = np.array(y_list, dtype=np.int64)\n    np.save(sx, X); np.save(sy, y)\n    print(f\"   → {name} : {X.shape}\")\n    return X, y\n \n \nprint(\"\\n🚀 Preprocessing v3 en cours...\")\nstart = time.time()\nX_train, y_train = process_split(train_df, \"train\")\nX_val,   y_val   = process_split(val_df,   \"val\")\nX_test,  y_test  = process_split(test_df,  \"test\")\nprint(f\"✅ Terminé en {(time.time()-start)/60:.1f} min\")\nprint(f\"   X_train={X_train.shape} · {X_train.nbytes/1e9:.2f} Go\")\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-20T12:03:56.697353Z","iopub.execute_input":"2026-05-20T12:03:56.698145Z","iopub.status.idle":"2026-05-20T16:04:17.426580Z","shell.execute_reply.started":"2026-05-20T12:03:56.698112Z","shell.execute_reply":"2026-05-20T16:04:17.425839Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class SignDataset(Dataset):\n    def __init__(self, X, y, augment=False):\n        self.X       = torch.tensor(X, dtype=torch.float32)\n        self.y       = torch.tensor(y, dtype=torch.long)\n        self.augment = augment\n \n    def __len__(self): return len(self.X)\n \n    def __getitem__(self, idx):\n        x = self.X[idx].clone()\n        if self.augment:\n            # Bruit sur les coordonnées\n            if torch.rand(1) > 0.5:\n                x = x + torch.randn_like(x) * 0.05\n            # Time shift\n            if torch.rand(1) > 0.5:\n                s = torch.randint(-8, 9, (1,)).item()\n                if s > 0:\n                    x = torch.cat([torch.zeros(s, x.shape[1]), x[:-s]], 0)\n                elif s < 0:\n                    x = torch.cat([x[-s:], torch.zeros(-s, x.shape[1])], 0)\n            # Flip LH ↔ RH (63 features chacun)\n            if torch.rand(1) > 0.5:\n                xf = x.clone()\n                xf[:, :63]    = x[:, 63:126]\n                xf[:, 63:126] = x[:, :63]\n                x = xf\n            # Scale aléatoire (simule distance caméra)\n            if torch.rand(1) > 0.5:\n                scale = torch.FloatTensor(1).uniform_(0.8, 1.2)\n                x = x * scale\n        return x, self.y[idx]\n \n \ntrain_loader = DataLoader(SignDataset(X_train, y_train, augment=True),\n                          batch_size=BATCH_SIZE, shuffle=True,\n                          num_workers=2, pin_memory=True)\nval_loader   = DataLoader(SignDataset(X_val,   y_val),\n                          batch_size=BATCH_SIZE, shuffle=False,\n                          num_workers=2, pin_memory=True)\ntest_loader  = DataLoader(SignDataset(X_test,  y_test),\n                          batch_size=BATCH_SIZE, shuffle=False,\n                          num_workers=2, pin_memory=True)\n \nXb, yb = next(iter(train_loader))\nprint(f\"✅ DataLoaders · batch X={Xb.shape} · y={yb.shape}\")\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-20T02:08:00.351183Z","iopub.execute_input":"2026-05-20T02:08:00.351417Z","iopub.status.idle":"2026-05-20T02:08:00.358548Z","shell.execute_reply.started":"2026-05-20T02:08:00.351398Z","shell.execute_reply":"2026-05-20T02:08:00.357803Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class PositionalEncoding(nn.Module):\n    \"\"\"Encode la position temporelle de chaque frame.\"\"\"\n    def __init__(self, d_model, max_len=N_FRAMES, dropout=0.1):\n        super().__init__()\n        self.dropout = nn.Dropout(dropout)\n        pe = torch.zeros(max_len, d_model)\n        pos = torch.arange(max_len).unsqueeze(1).float()\n        div = torch.exp(torch.arange(0, d_model, 2).float()\n                        * (-np.log(10000.0) / d_model))\n        pe[:, 0::2] = torch.sin(pos * div)\n        pe[:, 1::2] = torch.cos(pos * div)\n        self.register_buffer('pe', pe.unsqueeze(0))  # (1, T, d_model)\n \n    def forward(self, x):\n        return self.dropout(x + self.pe[:, :x.size(1)])\n \n \nclass SignTransformer(nn.Module):\n    \"\"\"\n    Transformer pour reconnaissance de gestes ASL.\n \n    Architecture :\n    Input (batch, 64, 159)\n    → Linear(159 → 128) + LayerNorm          : projection\n    → PositionalEncoding                      : position temporelle\n    → TransformerEncoder (4 couches, 4 têtes) : attention\n    → Mean pooling                            : (batch, 128)\n    → Dropout + Linear(128 → 250)            : classification\n    \"\"\"\n    def __init__(self, input_size=N_FEATURES, d_model=128,\n                 nhead=4, num_layers=4, num_classes=N_CLASSES,\n                 dropout=0.3):\n        super().__init__()\n \n        # Projection d'entrée\n        self.input_proj = nn.Sequential(\n            nn.Linear(input_size, d_model),\n            nn.LayerNorm(d_model)\n        )\n \n        # Encodage positionnel\n        self.pos_enc = PositionalEncoding(d_model, dropout=dropout)\n \n        # Transformer encoder\n        enc_layer = nn.TransformerEncoderLayer(\n            d_model         = d_model,\n            nhead           = nhead,\n            dim_feedforward = d_model * 4,  # 512\n            dropout         = dropout,\n            activation      = 'gelu',\n            batch_first     = True,\n            norm_first      = True          # Pre-LN → plus stable\n        )\n        self.transformer = nn.TransformerEncoder(enc_layer, num_layers=num_layers)\n \n        # Classifieur\n        self.classifier = nn.Sequential(\n            nn.Dropout(dropout),\n            nn.Linear(d_model, num_classes)\n        )\n \n    def forward(self, x, src_key_padding_mask=None):\n        # x : (batch, 64, 159)\n        x = self.input_proj(x)       # (batch, 64, 128)\n        x = self.pos_enc(x)          # (batch, 64, 128)\n        x = self.transformer(x, src_key_padding_mask=src_key_padding_mask)\n        x = x.mean(dim=1)            # mean pooling (batch, 128)\n        return self.classifier(x)    # (batch, 250)\n \n \nmodel    = SignTransformer().to(DEVICE)\nn_params = sum(p.numel() for p in model.parameters())\nprint(f\"✅ SignTransformer · {n_params:,} paramètres\")\n \nwith torch.no_grad():\n    dummy = torch.zeros(2, N_FRAMES, N_FEATURES).to(DEVICE)\n    out   = model(dummy)\n    print(f\"   Input  : {dummy.shape}\")\n    print(f\"   Output : {out.shape}  ← doit être (2, 250)\")\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-20T02:08:00.359566Z","iopub.execute_input":"2026-05-20T02:08:00.360389Z","iopub.status.idle":"2026-05-20T02:08:00.455421Z","shell.execute_reply.started":"2026-05-20T02:08:00.360355Z","shell.execute_reply":"2026-05-20T02:08:00.454664Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"LR        = 1e-3\nN_EPOCHS  = 80\nPATIENCE  = 15\nSAVE_PATH = f'{OUTPUT_PATH}/best_transformer.pt'\n \ncriterion = nn.CrossEntropyLoss(label_smoothing=0.1)\noptimizer = torch.optim.AdamW(\n    model.parameters(),\n    lr           = LR,\n    weight_decay = 1e-2,\n    betas        = (0.9, 0.98)   # betas recommandés pour Transformer\n)\n \n# Warmup 10 epochs → cosine decay\ndef lr_lambda(step):\n    warmup = len(train_loader) * 10   # 10 epochs de warmup\n    if step < warmup:\n        return step / warmup\n    progress = (step - warmup) / (len(train_loader) * N_EPOCHS - warmup)\n    return max(0.1, 0.5 * (1 + np.cos(np.pi * progress)))\n \nscheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)\n \nhistory = {'train_loss':[], 'train_acc':[], 'val_loss':[], 'val_acc':[]}\nbest_val_acc, patience_counter = 0.0, 0\n \nprint(f\"🚀 Entraînement SignTransformer\")\nprint(f\"   LR={LR} · Epochs={N_EPOCHS} · Warmup=10 epochs\")\nprint(f\"   Device : {DEVICE}\")\nprint(\"─\" * 72)\nprint(f\"{'Epoch':>6} | {'T.Loss':>8} | {'T.Acc':>7} | \"\n      f\"{'V.Loss':>8} | {'V.Acc':>7} | {'Gap':>6}\")\nprint(\"─\" * 72)\n \nstart = time.time()\n \nfor epoch in range(1, N_EPOCHS + 1):\n \n    # ── Train ───────────────────────────────────────────────\n    model.train()\n    tl, tc, tt = 0., 0, 0\n    for Xb, yb in train_loader:\n        Xb, yb = Xb.to(DEVICE), yb.to(DEVICE)\n \n        # Masque : frames nulles (paddées)\n        pad_mask = (Xb.sum(dim=-1) == 0)  # (batch, 64)\n \n        optimizer.zero_grad()\n        logits = model(Xb, src_key_padding_mask=pad_mask)\n        loss   = criterion(logits, yb)\n        loss.backward()\n        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n        optimizer.step()\n        scheduler.step()\n \n        tl += loss.item() * len(yb)\n        tc += (logits.argmax(1) == yb).sum().item()\n        tt += len(yb)\n \n    avg_tl, avg_ta = tl/tt, tc/tt\n \n    # ── Validation ──────────────────────────────────────────\n    model.eval()\n    vl, vc, vt = 0., 0, 0\n    with torch.no_grad():\n        for Xb, yb in val_loader:\n            Xb, yb  = Xb.to(DEVICE), yb.to(DEVICE)\n            pad_mask = (Xb.sum(dim=-1) == 0)\n            logits   = model(Xb, src_key_padding_mask=pad_mask)\n            vl += criterion(logits, yb).item() * len(yb)\n            vc += (logits.argmax(1) == yb).sum().item()\n            vt += len(yb)\n \n    avg_vl, avg_va = vl/vt, vc/vt\n    gap = avg_ta - avg_va\n \n    history['train_loss'].append(avg_tl)\n    history['train_acc'].append(avg_ta)\n    history['val_loss'].append(avg_vl)\n    history['val_acc'].append(avg_va)\n \n    cur_lr  = optimizer.param_groups[0]['lr']\n    gap_str = f\"⚠️{gap*100:.0f}%\" if gap > 0.20 else f\"{gap*100:.0f}%\"\n \n    print(f\"{epoch:>6} | {avg_tl:>8.4f} | {avg_ta*100:>6.2f}% | \"\n          f\"{avg_vl:>8.4f} | {avg_va*100:>6.2f}% | {gap_str}\")\n \n    if avg_va > best_val_acc:\n        best_val_acc, patience_counter = avg_va, 0\n        torch.save({\n            'epoch':       epoch,\n            'model_state': model.state_dict(),\n            'val_acc':     best_val_acc,\n            'history':     history,\n            'n_features':  N_FEATURES,\n            'n_frames':    N_FRAMES,\n            'n_classes':   N_CLASSES,\n            'label_map':   label_map,\n            'model_type':  'SignTransformer'\n        }, SAVE_PATH)\n        print(f\"         ✅ Sauvegardé (val_acc={best_val_acc*100:.2f}%)\")\n    else:\n        patience_counter += 1\n        if patience_counter >= PATIENCE:\n            print(f\"\\n⏹️  Early stopping epoch {epoch}\")\n            break\n \nprint(\"─\" * 72)\nprint(f\"✅ Terminé en {(time.time()-start)/60:.1f} min\")\nprint(f\"   Meilleure val_acc : {best_val_acc*100:.2f}%\")\n \n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-20T02:08:52.211097Z","iopub.execute_input":"2026-05-20T02:08:52.211834Z","iopub.status.idle":"2026-05-20T02:08:58.479140Z","shell.execute_reply.started":"2026-05-20T02:08:52.211796Z","shell.execute_reply":"2026-05-20T02:08:58.478128Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"ckpt = torch.load(SAVE_PATH)\nmodel.load_state_dict(ckpt['model_state'])\nmodel.eval()\n \nall_preds, all_labels = [], []\nwith torch.no_grad():\n    for Xb, yb in test_loader:\n        Xb = Xb.to(DEVICE)\n        pad_mask = (Xb.sum(dim=-1) == 0)\n        all_preds.extend(model(Xb, pad_mask).argmax(1).cpu().numpy())\n        all_labels.extend(yb.numpy())\n \nall_preds  = np.array(all_preds)\nall_labels = np.array(all_labels)\ntest_acc   = accuracy_score(all_labels, all_preds)\ntest_f1    = f1_score(all_labels, all_preds, average='weighted')\n \n# Top-5\ndef top5_acc(loader):\n    c, t = 0, 0\n    model.eval()\n    with torch.no_grad():\n        for Xb, yb in loader:\n            Xb = Xb.to(DEVICE)\n            pad_mask = (Xb.sum(dim=-1) == 0)\n            top5 = F.softmax(model(Xb, pad_mask), dim=1)\\\n                     .topk(5, dim=1).indices.cpu()\n            for i, lbl in enumerate(yb):\n                if lbl in top5[i]: c += 1\n            t += len(yb)\n    return c / t\n \ntop5 = top5_acc(test_loader)\nidx2sign = label_map['index_to_sign']\n \nprint(\"\\n\" + \"═\"*52)\nprint(\"📊 RÉSULTATS FINAUX — TEST SET\")\nprint(\"═\"*52)\nprint(f\"   Accuracy Top-1  : {test_acc*100:.2f}%\")\nprint(f\"   Accuracy Top-5  : {top5*100:.2f}%\")\nprint(f\"   F1-score (w.)   : {test_f1*100:.2f}%\")\nprint(f\"\\n   Objectif ≥ 80% : \"\n      f\"{'✅ ATTEINT 🎉' if test_acc >= 0.80 else f'❌ {test_acc*100:.1f}%'}\")\n \n# Courbes\nhist = ckpt['history']\nep   = range(1, len(hist['train_loss'])+1)\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n \naxes[0].plot(ep, hist['train_loss'], 'b-', lw=2, label='Train')\naxes[0].plot(ep, hist['val_loss'],   'r-', lw=2, label='Val')\naxes[0].set_title('Loss', fontweight='bold')\naxes[0].legend(); axes[0].grid(True, alpha=0.3)\n \naxes[1].plot(ep, [a*100 for a in hist['train_acc']], 'b-', lw=2, label='Train')\naxes[1].plot(ep, [a*100 for a in hist['val_acc']],   'r-', lw=2, label='Val')\naxes[1].axhline(test_acc*100, color='g', ls='--',\n                label=f'Test : {test_acc*100:.1f}%')\naxes[1].set_title('Accuracy (%)', fontweight='bold')\naxes[1].set_ylim(0, 100); axes[1].legend(); axes[1].grid(True, alpha=0.3)\n \nplt.suptitle(\n    f'Transformer · Test={test_acc*100:.1f}% · Top5={top5*100:.1f}% · F1={test_f1*100:.1f}%',\n    fontsize=13, fontweight='bold'\n)\nplt.tight_layout()\nplt.savefig(f'{OUTPUT_PATH}/transformer_results.png', dpi=150, bbox_inches='tight')\nplt.show()\nprint(\"✅ Sauvegardé : transformer_results.png\")\n ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-20T02:09:02.386528Z","iopub.execute_input":"2026-05-20T02:09:02.387221Z","iopub.status.idle":"2026-05-20T02:09:02.433509Z","shell.execute_reply.started":"2026-05-20T02:09:02.387180Z","shell.execute_reply":"2026-05-20T02:09:02.432605Z"}},"outputs":[],"execution_count":null}]}