{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.6.6","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Détection Multimodale de Deepfakes (Vidéo + Audio)\n\n**Contexte** : Conception d'une architecture multimodale robuste pour la détection de contenus générés par IA.\n\n**Adaptation expérimentale à la fiche problématique** : ce notebook implémente une preuve de concept légère basée sur le dataset Kaggle **Deepfake Detection Challenge (DFDC)**. Le dossier `train_sample_videos` est utilisé comme source annotée pour l'entraînement, la validation et un test interne stratifié. Le dossier officiel `test_videos` est pris en compte comme ensemble externe non annoté pour l'inférence, et éventuellement pour une expérience semi-supervisée par pseudo-labels. Dans un cadre scientifique, les métriques finales doivent rester calculées sur un jeu annoté jamais utilisé pour l'apprentissage afin d'éviter toute fuite de données.\n","metadata":{}},{"cell_type":"code","source":"import os\nimport random\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader, WeightedRandomSampler\nfrom torchvision.transforms import Compose, Resize, Normalize, ToPILImage, ToTensor\ntry:\n    from transformers import ViTModel, Wav2Vec2Model\n    TRANSFORMERS_AVAILABLE = True\nexcept Exception as exc:\n    print(f\"Transformers indisponible: {exc}\")\n    TRANSFORMERS_AVAILABLE = False\nimport cv2\nimport torchaudio\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import accuracy_score, f1_score, confusion_matrix, roc_auc_score, classification_report\nfrom tqdm.auto import tqdm\n\n# Reproductibilité\ndef seed_everything(seed=42):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed(seed)\n        torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nseed_everything(42)\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"Device used: {device}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-10T11:04:45.815919Z","iopub.execute_input":"2026-07-10T11:04:45.816341Z","iopub.status.idle":"2026-07-10T11:04:47.927681Z","shell.execute_reply.started":"2026-07-10T11:04:45.816284Z","shell.execute_reply":"2026-07-10T11:04:47.925735Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Chargement DFDC: données annotées + test externe non annoté\nTRAIN_DATA_DIR = \"/kaggle/input/competitions/deepfake-detection-challenge/train_sample_videos\"\nMETADATA_FILE = os.path.join(TRAIN_DATA_DIR, \"metadata.json\")\n\n# Chemin fourni dans l'énoncé + fallback Kaggle fréquent.\nTEST_DATA_DIR = \"/kaggle/input/competitions/deepfake-detection-challenge/test_videos\"\n\nLIMIT_LABELED_DATA = None  # None = utiliser toutes les vidéos annotées disponibles.\nLIMIT_EXTERNAL_TEST = None # None = utiliser toutes les vidéos externes disponibles.\n\n# Option de recherche: transductive/semi-supervisée. Si activée, les vidéos test non annotées\n# peuvent être pseudo-labellisées après un premier entraînement. Ne pas utiliser ce même\n# ensemble pour calculer les métriques finales, car il aura influencé le modèle.\nUSE_TEST_VIDEOS_FOR_SEMI_SUPERVISED_AUGMENTATION = False\nPSEUDO_LABEL_CONFIDENCE = 0.90\n\ndef load_labeled_metadata(data_dir, metadata_file, limit=None):\n    if os.path.exists(metadata_file):\n        df = pd.read_json(metadata_file).T.reset_index()\n        df = df.rename(columns={'index': 'filename'})\n        df['label'] = df['label'].map({'REAL': 0, 'FAKE': 1}).astype(int)\n        df['data_dir'] = data_dir\n        df = df[df['filename'].apply(lambda x: os.path.exists(os.path.join(data_dir, x)))].reset_index(drop=True)\n    else:\n        print(\"Dataset annoté non trouvé sur ce chemin, génération de fausses métadonnées pour valider le pipeline.\")\n        n = limit or 200\n        df = pd.DataFrame({\n            'filename': [f\"video_{i}.mp4\" for i in range(n)],\n            'label': [0 if i < n // 2 else 1 for i in range(n)],\n            'data_dir': data_dir\n        })\n\n    if limit is not None and len(df) > limit:\n        # Échantillonnage stratifié pour garder la proportion REAL/FAKE.\n        df = (df.groupby('label', group_keys=False)\n                .apply(lambda x: x.sample(min(len(x), max(1, int(limit * len(x) / len(df)))), random_state=42))\n                .sample(frac=1, random_state=42)\n                .head(limit)\n                .reset_index(drop=True))\n    return df\n\ndef load_unlabeled_videos(data_dir, limit=None):\n    if not os.path.exists(data_dir):\n        print(f\"Ensemble test externe introuvable: {data_dir}\")\n        return pd.DataFrame(columns=['filename', 'data_dir'])\n\n    filenames = sorted([f for f in os.listdir(data_dir) if f.lower().endswith(('.mp4', '.avi', '.mov', '.mkv'))])\n    if limit is not None:\n        filenames = filenames[:limit]\n    return pd.DataFrame({'filename': filenames, 'data_dir': data_dir})\n\ndf = load_labeled_metadata(TRAIN_DATA_DIR, METADATA_FILE, limit=LIMIT_LABELED_DATA)\nexternal_test_df = load_unlabeled_videos(TEST_DATA_DIR, limit=LIMIT_EXTERNAL_TEST)\n\nprint(f\"Taille du dataset annoté : {len(df)} vidéos\")\nprint(df['label'].value_counts().rename(index={0: 'REAL', 1: 'FAKE'}))\nprint(f\"Taille du test externe non annoté : {len(external_test_df)} vidéos\")\nprint(f\"Chemin test externe utilisé : {TEST_DATA_DIR}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-10T11:04:47.931091Z","iopub.execute_input":"2026-07-10T11:04:47.931576Z","iopub.status.idle":"2026-07-10T11:04:49.147429Z","shell.execute_reply.started":"2026-07-10T11:04:47.931495Z","shell.execute_reply":"2026-07-10T11:04:49.145926Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Pipeline de données multimodales\nclass MultimodalDataset(Dataset):\n    def __init__(self, dataframe, default_data_dir=None, num_frames=5, audio_duration=3.0, transform=None):\n        self.dataframe = dataframe.reset_index(drop=True)\n        self.default_data_dir = default_data_dir\n        self.num_frames = num_frames\n        self.audio_duration = audio_duration\n        self.transform = transform\n        self.target_sr = 16000 # Wav2Vec2 requiert 16kHz\n        self.has_labels = 'label' in self.dataframe.columns\n        self.has_data_dir = 'data_dir' in self.dataframe.columns\n        \n    def __len__(self):\n        return len(self.dataframe)\n    \n    def resolve_path(self, row):\n        data_dir = row['data_dir'] if self.has_data_dir else self.default_data_dir\n        return os.path.join(data_dir, row['filename'])\n    \n    def extract_frames(self, video_path):\n        frames = []\n        if os.path.exists(video_path):\n            cap = cv2.VideoCapture(video_path)\n            total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))\n            if total_frames > 0:\n                idxs = np.linspace(0, total_frames - 1, self.num_frames, dtype=int)\n                for i in idxs:\n                    cap.set(cv2.CAP_PROP_POS_FRAMES, i)\n                    ret, frame = cap.read()\n                    if ret:\n                        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)\n                        if self.transform:\n                            frame = self.transform(frame)\n                        frames.append(frame)\n            cap.release()\n        \n        if len(frames) == 0:\n            frames = [torch.zeros((3, 224, 224)) for _ in range(self.num_frames)]\n        elif len(frames) < self.num_frames:\n            # Répéter la dernière frame pour garder une taille fixe.\n            frames.extend([frames[-1]] * (self.num_frames - len(frames)))\n        return torch.stack(frames)\n\n    def extract_audio(self, video_path):\n        if os.path.exists(video_path):\n            try:\n                waveform, sr = torchaudio.load(video_path)\n                if sr != self.target_sr:\n                    resampler = torchaudio.transforms.Resample(orig_freq=sr, new_freq=self.target_sr)\n                    waveform = resampler(waveform)\n                if waveform.shape[0] > 1:\n                    waveform = torch.mean(waveform, dim=0, keepdim=True)\n                \n                target_len = int(self.target_sr * self.audio_duration)\n                if waveform.shape[1] < target_len:\n                    waveform = nn.functional.pad(waveform, (0, target_len - waveform.shape[1]))\n                else:\n                    waveform = waveform[:, :target_len]\n                return waveform.squeeze(0)\n            except Exception:\n                pass\n        return torch.zeros((int(self.target_sr * self.audio_duration),))\n\n    def __getitem__(self, idx):\n        row = self.dataframe.iloc[idx]\n        path = self.resolve_path(row)\n        frames = self.extract_frames(path)\n        audio = self.extract_audio(path)\n        if self.has_labels:\n            label = torch.tensor(row['label'], dtype=torch.float32)\n            return frames, audio, label\n        return frames, audio, row['filename']\n\ntransform = Compose([\n    ToPILImage(),\n    Resize((224, 224)),\n    ToTensor(),\n    Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-10T11:04:49.14969Z","iopub.execute_input":"2026-07-10T11:04:49.150318Z","iopub.status.idle":"2026-07-10T11:04:49.17843Z","shell.execute_reply.started":"2026-07-10T11:04:49.15023Z","shell.execute_reply":"2026-07-10T11:04:49.176552Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Création des sous-ensembles annotés avec stratification + correction du déséquilibre\nBATCH_SIZE = 4\n\ndef make_stratified_splits(dataframe, seed=42):\n    if dataframe['label'].nunique() < 2 or dataframe['label'].value_counts().min() < 2:\n        print(\"Stratification impossible: au moins une classe est trop petite. Split aléatoire utilisé.\")\n        shuffled = dataframe.sample(frac=1, random_state=seed).reset_index(drop=True)\n        train_size = int(0.7 * len(shuffled))\n        val_size = int(0.15 * len(shuffled))\n        return shuffled.iloc[:train_size], shuffled.iloc[train_size:train_size + val_size], shuffled.iloc[train_size + val_size:]\n\n    train_df, temp_df = train_test_split(\n        dataframe,\n        test_size=0.30,\n        random_state=seed,\n        stratify=dataframe['label']\n    )\n    val_df, test_df = train_test_split(\n        temp_df,\n        test_size=0.50,\n        random_state=seed,\n        stratify=temp_df['label']\n    )\n    return train_df.reset_index(drop=True), val_df.reset_index(drop=True), test_df.reset_index(drop=True)\n\ntrain_df, val_df, test_df = make_stratified_splits(df)\n\ntrain_dataset = MultimodalDataset(train_df, transform=transform)\nval_dataset = MultimodalDataset(val_df, transform=transform)\ntest_dataset = MultimodalDataset(test_df, transform=transform)\nexternal_test_dataset = MultimodalDataset(external_test_df, transform=transform)\n\n# Sampler pondéré: équilibre REAL/FAKE dans les batches sans supprimer de données.\nclass_counts = train_df['label'].value_counts().sort_index()\nclass_weights = {label: len(train_df) / (len(class_counts) * count) for label, count in class_counts.items()}\nsample_weights = train_df['label'].map(class_weights).astype(float).values\ntrain_sampler = WeightedRandomSampler(\n    weights=torch.DoubleTensor(sample_weights),\n    num_samples=len(sample_weights),\n    replacement=True\n)\n\ntrain_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=train_sampler)\nval_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False)\ntest_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)\nexternal_test_loader = DataLoader(external_test_dataset, batch_size=BATCH_SIZE, shuffle=False) if len(external_test_dataset) else None\n\n# pos_weight augmente la pénalité sur la classe minoritaire positive (FAKE=1) dans BCEWithLogitsLoss.\nn_real = int(class_counts.get(0, 0))\nn_fake = int(class_counts.get(1, 0))\npos_weight_value = n_real / max(n_fake, 1)\npos_weight = torch.tensor([pos_weight_value], dtype=torch.float32, device=device)\n\nprint(f\"Tailles annotées (Train/Val/Test interne) : {len(train_df)} / {len(val_df)} / {len(test_df)}\")\nprint(\"Distribution Train :\")\nprint(train_df['label'].value_counts().rename(index={0: 'REAL', 1: 'FAKE'}))\nprint(f\"pos_weight BCE (REAL/FAKE) : {pos_weight_value:.4f}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-10T11:04:49.181348Z","iopub.execute_input":"2026-07-10T11:04:49.182579Z","iopub.status.idle":"2026-07-10T11:04:49.226782Z","shell.execute_reply.started":"2026-07-10T11:04:49.181929Z","shell.execute_reply":"2026-07-10T11:04:49.2253Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Architecture du modèle Multimodal\n# USE_PRETRAINED_TRANSFORMERS=True essaie ViT + Wav2Vec2. Si Kaggle n'a pas Internet/cache,\n# le notebook bascule automatiquement vers une baseline CNN vidéo + CNN audio sans téléchargement.\nUSE_PRETRAINED_TRANSFORMERS = True\nVIT_MODEL_NAME_OR_PATH = 'google/vit-base-patch16-224-in21k'\nWAV2VEC_MODEL_NAME_OR_PATH = 'facebook/wav2vec2-base'\n\nclass OfflineVideoEncoder(nn.Module):\n    def __init__(self, out_dim=256):\n        super().__init__()\n        self.features = nn.Sequential(\n            nn.Conv2d(3, 32, kernel_size=3, stride=2, padding=1),\n            nn.BatchNorm2d(32),\n            nn.ReLU(inplace=True),\n            nn.MaxPool2d(2),\n            nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1),\n            nn.BatchNorm2d(64),\n            nn.ReLU(inplace=True),\n            nn.MaxPool2d(2),\n            nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1),\n            nn.BatchNorm2d(128),\n            nn.ReLU(inplace=True),\n            nn.AdaptiveAvgPool2d((1, 1))\n        )\n        self.proj = nn.Linear(128, out_dim)\n\n    def forward(self, frames):\n        b, f, c, h, w = frames.shape\n        x = frames.view(b * f, c, h, w)\n        x = self.features(x).view(b, f, -1).mean(dim=1)\n        return self.proj(x)\n\nclass OfflineAudioEncoder(nn.Module):\n    def __init__(self, out_dim=256, sample_rate=16000):\n        super().__init__()\n        self.mel = torchaudio.transforms.MelSpectrogram(\n            sample_rate=sample_rate,\n            n_fft=400,\n            hop_length=160,\n            n_mels=64\n        )\n        self.features = nn.Sequential(\n            nn.Conv2d(1, 16, kernel_size=3, padding=1),\n            nn.BatchNorm2d(16),\n            nn.ReLU(inplace=True),\n            nn.MaxPool2d(2),\n            nn.Conv2d(16, 32, kernel_size=3, padding=1),\n            nn.BatchNorm2d(32),\n            nn.ReLU(inplace=True),\n            nn.MaxPool2d(2),\n            nn.Conv2d(32, 64, kernel_size=3, padding=1),\n            nn.BatchNorm2d(64),\n            nn.ReLU(inplace=True),\n            nn.AdaptiveAvgPool2d((1, 1))\n        )\n        self.proj = nn.Linear(64, out_dim)\n\n    def forward(self, audio):\n        mel = self.mel(audio).clamp_min(1e-6).log().unsqueeze(1)\n        x = self.features(mel).flatten(1)\n        return self.proj(x)\n\nclass MultimodalDetector(nn.Module):\n    def __init__(self, freeze=True, use_pretrained_transformers=True):\n        super().__init__()\n        self.use_transformers = False\n\n        if use_pretrained_transformers and TRANSFORMERS_AVAILABLE:\n            try:\n                self.vit = ViTModel.from_pretrained(VIT_MODEL_NAME_OR_PATH)\n                self.wav2vec = Wav2Vec2Model.from_pretrained(WAV2VEC_MODEL_NAME_OR_PATH)\n                self.use_transformers = True\n                video_dim = self.vit.config.hidden_size\n                audio_dim = self.wav2vec.config.hidden_size\n                print(\"Encodeurs pré-entraînés chargés: ViT + Wav2Vec2\")\n\n                if freeze:\n                    for p in self.vit.parameters():\n                        p.requires_grad = False\n                    for p in self.wav2vec.parameters():\n                        p.requires_grad = False\n            except Exception as exc:\n                print(\"Impossible de charger ViT/Wav2Vec2 depuis HuggingFace ou le cache local.\")\n                print(f\"Raison: {exc}\")\n                print(\"Bascule vers encodeurs CNN locaux sans téléchargement.\")\n\n        if not self.use_transformers:\n            self.video_encoder = OfflineVideoEncoder(out_dim=256)\n            self.audio_encoder = OfflineAudioEncoder(out_dim=256)\n            video_dim = 256\n            audio_dim = 256\n\n        self.classifier = nn.Sequential(\n            nn.Linear(video_dim + audio_dim, 256),\n            nn.ReLU(),\n            nn.Dropout(0.3),\n            nn.Linear(256, 1)\n        )\n\n    def forward(self, frames, audio):\n        if self.use_transformers:\n            b, f, c, h, w = frames.shape\n            v_out = self.vit(pixel_values=frames.view(b * f, c, h, w))\n            v_emb = v_out.last_hidden_state[:, 0, :].view(b, f, -1).mean(dim=1)\n            a_out = self.wav2vec(audio)\n            a_emb = a_out.last_hidden_state.mean(dim=1)\n        else:\n            v_emb = self.video_encoder(frames)\n            a_emb = self.audio_encoder(audio)\n\n        fused = torch.cat((v_emb, a_emb), dim=1)\n        return self.classifier(fused).squeeze(1)\n\nmodel = MultimodalDetector(\n    freeze=True,\n    use_pretrained_transformers=USE_PRETRAINED_TRANSFORMERS\n).to(device)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-10T11:04:49.229384Z","iopub.execute_input":"2026-07-10T11:04:49.22995Z","iopub.status.idle":"2026-07-10T11:04:49.281655Z","shell.execute_reply.started":"2026-07-10T11:04:49.229736Z","shell.execute_reply":"2026-07-10T11:04:49.280343Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Boucle d'entraînement supervisé sur les vidéos annotées\ncriterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)\noptimizer = torch.optim.AdamW((p for p in model.parameters() if p.requires_grad), lr=1e-4)\n\nepochs = 3 # Limité à 3 epochs\n\ndef train_one_stage(model, loader, criterion, optimizer, epochs=3, stage_name=\"supervisé\"):\n    for epoch in range(epochs):\n        model.train()\n        total_loss = 0.0\n        for frames, audio, labels in tqdm(loader, desc=f\"{stage_name} - Epoch {epoch+1}/{epochs}\"):\n            frames, audio, labels = frames.to(device), audio.to(device), labels.to(device)\n            \n            optimizer.zero_grad()\n            outputs = model(frames, audio)\n            loss = criterion(outputs, labels)\n            loss.backward()\n            optimizer.step()\n            \n            total_loss += loss.item() * frames.size(0)\n            \n        print(f\"Train Loss ({stage_name}, Epoch {epoch+1}): {total_loss / len(loader.dataset):.4f}\")\n\ntrain_one_stage(model, train_loader, criterion, optimizer, epochs=epochs, stage_name=\"supervisé\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-10T11:04:49.284002Z","iopub.execute_input":"2026-07-10T11:04:49.284491Z","execution_failed":"2026-07-10T14:00:49.187Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Option semi-supervisée: pseudo-labellisation du dossier test_videos non annoté\n# À activer uniquement si l'expérience est déclarée comme transductive/semi-supervisée.\ndef predict_unlabeled(model, loader):\n    rows = []\n    if loader is None:\n        return pd.DataFrame(columns=['filename', 'prob_fake', 'pseudo_label', 'confidence'])\n\n    model.eval()\n    with torch.no_grad():\n        for frames, audio, filenames in tqdm(loader, desc=\"Inférence test externe\"):\n            logits = model(frames.to(device), audio.to(device))\n            prob_fake = torch.sigmoid(logits).cpu().numpy()\n            for filename, prob in zip(filenames, prob_fake):\n                label = int(prob >= 0.5)\n                confidence = prob if label == 1 else 1 - prob\n                rows.append({\n                    'filename': filename,\n                    'prob_fake': float(prob),\n                    'pseudo_label': label,\n                    'confidence': float(confidence)\n                })\n    return pd.DataFrame(rows)\n\nexternal_predictions_df = predict_unlabeled(model, external_test_loader)\nif len(external_predictions_df):\n    external_predictions_df.to_csv('dfdc_external_test_predictions.csv', index=False)\n    print(\"Prédictions du test externe sauvegardées dans dfdc_external_test_predictions.csv\")\n    print(external_predictions_df.head())\n\nif USE_TEST_VIDEOS_FOR_SEMI_SUPERVISED_AUGMENTATION and len(external_predictions_df):\n    confident = external_predictions_df[external_predictions_df['confidence'] >= PSEUDO_LABEL_CONFIDENCE].copy()\n    confident = confident.rename(columns={'pseudo_label': 'label'})[['filename', 'label']]\n    confident['data_dir'] = TEST_DATA_DIR\n\n    if len(confident):\n        print(f\"Pseudo-labels conservés : {len(confident)} / {len(external_predictions_df)}\")\n        augmented_train_df = pd.concat([train_df, confident], ignore_index=True)\n        augmented_train_dataset = MultimodalDataset(augmented_train_df, transform=transform)\n\n        aug_counts = augmented_train_df['label'].value_counts().sort_index()\n        aug_weights = {label: len(augmented_train_df) / (len(aug_counts) * count) for label, count in aug_counts.items()}\n        aug_sample_weights = augmented_train_df['label'].map(aug_weights).astype(float).values\n        augmented_sampler = WeightedRandomSampler(\n            weights=torch.DoubleTensor(aug_sample_weights),\n            num_samples=len(aug_sample_weights),\n            replacement=True\n        )\n        augmented_loader = DataLoader(augmented_train_dataset, batch_size=BATCH_SIZE, sampler=augmented_sampler)\n\n        aug_real = int(aug_counts.get(0, 0))\n        aug_fake = int(aug_counts.get(1, 0))\n        augmented_pos_weight = torch.tensor([aug_real / max(aug_fake, 1)], dtype=torch.float32, device=device)\n        augmented_criterion = nn.BCEWithLogitsLoss(pos_weight=augmented_pos_weight)\n\n        train_one_stage(model, augmented_loader, augmented_criterion, optimizer, epochs=1, stage_name=\"pseudo-labels\")\n    else:\n        print(\"Aucun pseudo-label assez confiant: entraînement semi-supervisé ignoré.\")\nelse:\n    print(\"Pseudo-labellisation désactivée: le test externe reste réservé à l'inférence.\")\n","metadata":{"trusted":true,"execution":{"execution_failed":"2026-07-10T14:00:49.188Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Évaluation scientifique sur validation et test interne annotés\n# Le test externe DFDC n'a pas de labels publics: il sert à l'inférence, pas aux métriques finales.\ndef evaluate_labeled(model, loader, split_name=\"Test interne\"):\n    model.eval()\n    preds, probs, true_labels = [], [], []\n\n    with torch.no_grad():\n        for frames, audio, labels in tqdm(loader, desc=f\"Évaluation - {split_name}\"):\n            out = model(frames.to(device), audio.to(device))\n            prob = torch.sigmoid(out).cpu().numpy()\n            probs.extend(prob)\n            preds.extend((prob >= 0.5).astype(int))\n            true_labels.extend(labels.numpy())\n\n    acc = accuracy_score(true_labels, preds)\n    f1 = f1_score(true_labels, preds, zero_division=0)\n    auc = roc_auc_score(true_labels, probs) if len(np.unique(true_labels)) == 2 else np.nan\n    cm = confusion_matrix(true_labels, preds)\n\n    print(f\"\\n=== Résultats Expérimentaux - {split_name} ===\")\n    print(f\"Accuracy : {acc:.4f}\")\n    print(f\"F1-Score : {f1:.4f}\")\n    print(f\"AUC-ROC  : {auc:.4f}\" if not np.isnan(auc) else \"AUC-ROC  : non calculable (une seule classe présente)\")\n    print(\"\\nMatrice de confusion :\\n\", cm)\n    print(\"\\nRapport par classe :\")\n    print(classification_report(true_labels, preds, target_names=['REAL', 'FAKE'], zero_division=0))\n    return {'accuracy': acc, 'f1': f1, 'auc': auc, 'confusion_matrix': cm}\n\nval_metrics = evaluate_labeled(model, val_loader, \"Validation\")\ntest_metrics = evaluate_labeled(model, test_loader, \"Test interne annoté\")\ngeneralization_gap = val_metrics['accuracy'] - test_metrics['accuracy']\nprint(f\"\\nGeneralization gap (Accuracy validation - test interne) : {generalization_gap:.4f}\")\n","metadata":{"trusted":true,"execution":{"execution_failed":"2026-07-10T14:00:49.188Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Conclusion sur les métriques et limites\n\n**Analyse des métriques (Accuracy, F1-Score, AUC-ROC)** :\nLes métriques sont calculées sur des sous-ensembles annotés issus de `train_sample_videos`, avec split stratifié pour préserver la proportion REAL/FAKE. Le déséquilibre des classes est traité par deux mécanismes complémentaires : `WeightedRandomSampler` pendant l'entraînement et `pos_weight` dans `BCEWithLogitsLoss`.\n\n**Prise en compte de `test_videos`** :\nLe dossier `/kaggle/input/competitions/deepfake-detection-challenge/test_videos` est chargé comme test externe non annoté. Il permet de produire des prédictions et, si l'option est explicitement activée, de mener une expérience semi-supervisée par pseudo-labels. Dans ce cas, il faut déclarer l'expérience comme transductive et ne pas présenter ce même ensemble comme évaluation finale indépendante.\n\n**Évaluation du Generalization Gap** :\nLa différence de précision entre validation et test interne annoté constitue le *generalization gap*. Un faible gap soutient la capacité de généralisation du modèle, mais il reste nécessaire de valider sur un vrai dataset externe annoté pour confirmer les hypothèses de la fiche problématique.\n\n**Limites** :\n- Le test officiel DFDC utilisé ici n'est pas annoté publiquement ; il ne permet donc pas de calculer Accuracy/F1/AUC sans labels.\n- La fusion par concaténation simple reste une baseline et ne modélise pas finement les relations temporelles audio-vidéo.\n- Les attaques adversariales et les tests cross-dataset restent à ajouter pour couvrir entièrement le protocole de recherche.\n","metadata":{}}]}