diff --git a/MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis/Argument_Analysis_Observatoire-1-Initiation.ipynb b/MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis/Argument_Analysis_Observatoire-1-Initiation.ipynb new file mode 100644 index 0000000000..30965151ef --- /dev/null +++ b/MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis/Argument_Analysis_Observatoire-1-Initiation.ipynb @@ -0,0 +1,1287 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8845eb77", + "metadata": {}, + "source": [ + "# Observatoire des formes relationnelles — Cas 1\n", + "\n", + "## Bumble « Opening Moves » (mars–avril 2024) — DiD imparfait sur avis datés\n", + "\n", + "*Cas inaugural de l'Observatoire (#13303) — protocole de preuve #13309 —\n", + "instrument $G_t^{arg} \\to G_{t+1}^{arg}$ rebranché depuis\n", + "[`Argument_Analysis_Dated_Graphs.ipynb`](Argument_Analysis_Dated_Graphs.ipynb)\n", + "(livré #13310).*\n", + "\n", + "`Grain: DEEP/research-code — lane myia-po-2027:CoursIA — prev: MED/guard #16388`\n" + ] + }, + { + "cell_type": "markdown", + "id": "815dea15", + "metadata": {}, + "source": [ + "## 1. Position dans le protocole\n", + "\n", + "Premier **cas** de l'Observatoire. La séquence du protocole est tenue :\n", + "\n", + "1. **Protocole** (#13309) — règle de preuve, barreau 1→3, biais de sélection nommé.\n", + "2. **Instrument** (#13310) — l'écart structurel daté `d_struct`, son plancher split-half, son contrôle positif par construction.\n", + "3. **Cas** (présent notebook) — premier candidat nommé dans le body de l'EPIC : Bumble « Opening Moves », rupture datée, bras contrôle Hinge sans changement sur la même fenêtre.\n", + "\n", + "**Barreau visé** : 1 → éventuellement 2. **Jamais 3** (toute conclusion causale récusée par construction). Cf. #13303 §Volet Désir.\n" + ] + }, + { + "cell_type": "markdown", + "id": "fcbfe983", + "metadata": {}, + "source": [ + "## 2. Pré-inscription\n", + "\n", + "Rappel — voir commentaire d'inscription (#16410) pour la version complète.\n", + "\n", + "| Élément | Valeur |\n", + "|---|---|\n", + "| **Rupture** | Bumble « Opening Moves » — déploiement **2024-04-30** (couverture presse 2024-05-01) |\n", + "| **Fenêtre pré** | 2023-10-15 → 2024-04-29 (6 mois) |\n", + "| **Fenêtre post** | 2024-05-15 → 2024-11-15 (6 mois + tampon de rollout 15 j) |\n", + "| **Bras traité** | r/Bumble (textes publics Reddit datés) |\n", + "| **Bras contrôle** | r/hingeapp (aucun changement de règle d'initiation sur la fenêtre) |\n", + "| **Corpus source** | Arctic Shift (`arctic-shift.photon-reddit.com`) |\n", + "\n", + "**Hypothèses signées** :\n", + "\n", + "- **H₀** : `Δ_struct(Bumble_post, Bumble_pre) − Δ_struct(hingeapp_post, hingeapp_pre) ≤ plancher split-half`. Les distributions de propositions n décalent pas différentiellement.\n", + "- **H₁** : décalage positif sur le bras traité uniquement, hausse de `INIT_NORM_ANY` et `OPENING_MOVE_REACT`, baisse de `INIT_NORM_W`, avec `Δ_struct(traité) > Δ_struct(contrôle) + plancher`.\n", + "\n", + "**Biais de sélection signé** : r/Bumble sur-représente les utilisateurs investis et les événements saillants. **Direction** : pousse vers H₁ (la rupture elle-même fabrique des posts `OPENING_MOVE_REACT`). Le bras contrôle n'absorbe pas ce biais de saillance spécifique au traité.\n", + "\n", + "**Plafond revendiqué** : barreau associatif au mieux, « quasi-expérimental » inatteignable avec ce corpus (commentaire absent / utilisateurs silencieux / résiliés / hors-app / non-anglophones).\n" + ] + }, + { + "cell_type": "markdown", + "id": "6cf1854a", + "metadata": {}, + "source": [ + "## 3. Engagement de corpus\n", + "\n", + "**Politique dataset (CLAUDE.md §Secrets-hygiene)** : aucun corpus brut commité.\n", + "Seuls les **agrégats**, **comptes** et **verbatims bornés** (≤5 par cellule de\n", + "mesure, anonymisés : URLs retirées, `/u/` et `@` redactés) entrent dans le\n", + "notebook. Aucun identifiant auteur n'est conservé.\n", + "\n", + "**Source** : Arctic Shift, endpoint `GET /api/posts/search`, paramètres\n", + "`subreddit`, `after`, `before`, `limit` (max 100/requête).\n", + "\n", + "**Échantillonnage stratifié mensuel** : chaque fenêtre (6 mois) est découpée en\n", + "6 seaux ~30 jours ; on tire par seau un nombre borné de posts récents (~9).\n", + "Comptes par seau imprimés à l'exécution (sanity de fenêtre : l'agrégat\n", + "date_min/date_max doit couvrir la période pré-inscrite). Objectif ≈ 50 textes\n", + "publics par (bras × fenêtre), chacun **classifié** (aucun sous-échantillon) —\n", + "4 cellules × ~50 = ~200 classifications, modèle local Ollama.\n", + "\n", + "**Modèle d'extraction** : Ollama local `qwen2.5:7b-instruct-q4_K_M`,\n", + "température 0, prompt épinglé (catégories + oppositions), schéma JSON épinglé.\n", + "Aucun appel distant à clé d'API.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d8dba799", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:19:37.696569Z", + "iopub.status.busy": "2026-09-16T17:19:37.696438Z", + "iopub.status.idle": "2026-09-16T17:19:37.835256Z", + "shell.execute_reply": "2026-09-16T17:19:37.834703Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "periode t (jours 1-30) : 30 enonces (28 assertifs, 2 questions), 22 relations exprimees\n", + "periode t+1 (jours 31-60) : 16 enonces (15 assertifs, 1 questions), 12 relations exprimees\n", + "\n", + "Apercu des 5 premiers enonces de la periode t :\n", + " jour 1 | p1 | assertif | (aucune)\n", + " jour 2 | p2 | assertif | CA->p1\n", + " jour 3 | p3 | assertif | CA->p2\n", + " jour 4 | p4 | assertif | CA->p3\n", + " jour 5 | p5 | assertif | RA->p4\n", + "G_t : 13 noeuds-I, 11 noeuds de scheme (9 CA, 2 RA), 2 exclus\n", + " exclu : p13 -- C1 : acte non assertif\n", + " exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)\n", + "G_t+1 : 14 noeuds-I, 12 noeuds de scheme (10 CA, 2 RA), 3 exclus\n", + " exclu : p13 -- C1 : acte non assertif\n", + " exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)\n", + " exclu : p17 -- C3 : singleton (aucune relation dans la fenetre)\n", + "Classes AIF declarees dans l'ontologie : ['CA-node', 'Conflict', 'I-node', 'RA-node']\n", + "Conformite vocabulaire : ['CA-node', 'I-node', 'RA-node'] <= classes declarees : OK\n", + "\n", + "G_t serialise : 59 triplets AIF\n", + "@prefix aif: .\n", + "\n", + "aif:p10 a aif:I-node ;\n", + " aif:RAedge aif:RA_p10_p9 ;\n", + " aif:text \"proposition p10\" .\n", + "\n", + "aif:p11 a aif:I-node ;\n", + " aif:CAedge aif:CA_p11_p6 ;\n", + " aif:text \"proposition p11\" .\n", + "\n", + "aif:p12 a aif:I-node ;\n", + " aif:CAedge aif:CA_p12_p9 ;\n", + " aif:text \"proposition p12\" .\n", + "\n", + "aif:p18 a aif:I-node ;\n", + " aif:CAedge aif:CA_p18_p1 ;\n", + " aif:text \"proposition p18\" .\n", + "\n", + "aif:p5 a aif:I-node ;\n", + " aif:RAedge aif:RA_p5_p4 ;\n", + " aif:text \"proposition p5\" .\n", + "\n", + "aif:CA_p11_p6 a aif:CA-node ;\n", + " \n", + "AF_t : 13 arguments, 9 attaques\n", + "AF_t+1 : 14 arguments, 10 attaques\n", + "\n", + "E_t (grounded, 8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']\n", + "E_t+1 (grounded, 8/14) : ['p10', 'p15', 'p16', 'p2', 'p4', 'p5', 'p7', 'p9']\n", + "Sanity : d(G, G) = 0 sur les trois composantes : OK\n", + "Moitie a (jours 1-15) : 13 arguments, 9 attaques | grounded 8\n", + "Moitie b (jours 16-30) : 12 arguments, 8 attaques | grounded 7\n", + "\n", + "Plancher de bruit (split moities de la periode t) :\n", + " d_noeuds = 0.077\n", + " d_att = 0.111\n", + " d_sem = 0.125\n", + "composante ecart t->t+1 plancher rapport verdict\n", + "------------------------------------------------------------------------\n", + "d_noeuds 0.200 0.077 2.6x DEPASSE\n", + "d_att 0.273 0.111 2.5x DEPASSE\n", + "d_sem 0.667 0.125 5.3x DEPASSE\n", + "Entrees dans l'extension grounded (acceptes en t+1, pas en t) : ['p15', 'p16', 'p7', 'p9']\n", + "Sorties de l'extension grounded (acceptes en t, plus en t+1) : ['p11', 'p12', 'p18', 'p8']\n", + "Lecture des cascades :\n", + " - p15->p12 (non contré) fait sortir p12 ; p12 sortant, p9 n'est plus\n", + " contré -> p9 entre ; et p9 dans l'extension défait p8 -> p7 entre\n", + " (son attaquant p8 est desormais defait).\n", + " - p8 sort : il exigeait la defaite de son attaquant p9, qui n'etait\n", + " defait QUE par p12 -- p12 sortant, p8 perd sa defense.\n", + " - p16->p11 fait sortir p11 (non contré).\n", + " - p18 n'est plus entendu en t+1 : son attaque disparait (avec son noeud).\n", + " - p1 reste dehors : son attaquant p2 n'est jamais defait (p3, seul\n", + " attaquant de p2, n'entre jamais dans l'extension -- p4 indéfaisable).\n", + "E_ctrl = ['p10', 'p11', 'p12', 'p18', 'p3', 'p5', 'p8', 'p_ctrl']\n", + "\n", + "attendu (ecrit avant) : d_sem = 0.4, d_att = 0.1\n", + "mesure : d_sem = 0.400, d_att = 0.100\n", + "\n", + "Controle positif : d_sem CONFORME, d_att CONFORME a l'attendu ecrit\n", + "p4 sorti : True | p2 sorti : True | p3 entre : True | p_ctrl entre : True\n", + "Resume de l'instrument (toute mesure d'ecart avec son plancher) :\n", + " plancher_bruit : {'d_noeuds': 0.077, 'd_att': 0.111, 'd_sem': 0.125}\n", + " ecart_inter_dates : {'d_noeuds': 0.2, 'd_att': 0.273, 'd_sem': 0.667}\n", + " controle_positif : {'d_sem_attendu': 0.4, 'd_sem_mesure': 0.4, 'd_att_attendu': 0.1, 'd_att_mesure': 0.1}\n", + " exclusions : {'t': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)'}, 't+1': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)', 'p17': 'C3 : singleton (aucune relation dans la fenetre)'}}\n", + "Lecture cumulative :\n", + " G_cumul_t : 13 arguments, 9 attaques\n", + " G_cumul_t1 : 15 arguments, 11 attaques\n", + "\n", + "Hypothese monotone sur ce corpus :\n", + " args G_cumul_t inclus dans G_cumul_t1 : True\n", + " attacks G_cumul_t inclus dans G_cumul_t1 : True\n", + " -> VERIFIEE PAR CONSTRUCTION : sur des corpus emboites, build_aif_graph\n", + " n'exclut jamais un noeud deja relie (C3 ne frappe que les singletons, et\n", + " les relations s'accumulent). Ici la monotonie est un theoreme de\n", + " l'instrument ; elle ne le resterait pas devant un corpus reel retire des enonces.\n", + "Grounded de G_cumul_t (8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']\n", + "Hors grounded (7) : ['p1', 'p15', 'p16', 'p3', 'p6', 'p7', 'p9']\n", + "\n", + "Retrait d'un argument DEFAIT (hors grounded) — effet mesure un par un :\n", + " retrait 'p1': d_noeuds=0.133 d_att=0.182 d_sem=0.111 d_sem BOUGE (0.111)\n", + " retrait 'p15': d_noeuds=0.067 d_att=0.091 d_sem=0.500 d_sem BOUGE (0.500)\n", + " retrait 'p16': d_noeuds=0.067 d_att=0.091 d_sem=0.200 d_sem BOUGE (0.200)\n", + " retrait 'p3': d_noeuds=0.067 d_att=0.182 d_sem=0.000 d_sem immobile\n", + " retrait 'p6': d_noeuds=0.067 d_att=0.182 d_sem=0.000 d_sem immobile\n", + " retrait 'p7': d_noeuds=0.067 d_att=0.182 d_sem=0.200 d_sem BOUGE (0.200)\n", + " retrait 'p9': d_noeuds=0.133 d_att=0.182 d_sem=0.455 d_sem BOUGE (0.455)\n", + " -> 2 retraits laissent grounded inchange ; 5 le font bouger\n", + " (un defait qui ATTAQUAIT un candidat peut, en disparaisant, le faire accepter)\n", + "\n", + "Retrait de 'p10' [ACCEPTE, dans grounded] :\n", + " d_noeuds = 0.067 d_att = 0.000 d_sem = 0.111\n", + "\n", + "Ce que l'instrument fait sous violation :\n", + " d_noeuds > 0 pour TOUT retrait : la mesure structurelle voit chaque disparition ;\n", + " d_sem ne bouge QUE si le retrait touche l'extension grounded (directement, ou via\n", + " un attaquant disparu qui libere un candidat) ;\n", + " AUCUNE des deux mesures ne dit RETRAIT vs APPARITION : le sens du mouvement n'est\n", + " pas dans l'ecart de graphes — il est dans les evenements dates du corpus. C'est\n", + " la limite explicite que l'hypothese monotone permettait de ne pas poser.\n", + "Exercice 1 a completer : fonction definie, a vous de l'appeler sur af_t / af_t1\n", + "Exercice 2 a completer : fonction definie, a vous de produire la distribution\n", + "Exercice 3 a completer : fonctions definies, a vous de produire le classement\n", + "periode t (jours 1-30) : 30 enonces (28 assertifs, 2 questions), 22 relations exprimees\n", + "periode t+1 (jours 31-60) : 16 enonces (15 assertifs, 1 questions), 12 relations exprimees\n", + "\n", + "Apercu des 5 premiers enonces de la periode t :\n", + " jour 1 | p1 | assertif | (aucune)\n", + " jour 2 | p2 | assertif | CA->p1\n", + " jour 3 | p3 | assertif | CA->p2\n", + " jour 4 | p4 | assertif | CA->p3\n", + " jour 5 | p5 | assertif | RA->p4\n", + "G_t : 13 noeuds-I, 11 noeuds de scheme (9 CA, 2 RA), 2 exclus\n", + " exclu : p13 -- C1 : acte non assertif\n", + " exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)\n", + "G_t+1 : 14 noeuds-I, 12 noeuds de scheme (10 CA, 2 RA), 3 exclus\n", + " exclu : p13 -- C1 : acte non assertif\n", + " exclu : p14 -- C3 : singleton (aucune relation dans la fenetre)\n", + " exclu : p17 -- C3 : singleton (aucune relation dans la fenetre)\n", + "Classes AIF declarees dans l'ontologie : ['CA-node', 'Conflict', 'I-node', 'RA-node']\n", + "Conformite vocabulaire : ['CA-node', 'I-node', 'RA-node'] <= classes declarees : OK\n", + "\n", + "G_t serialise : 59 triplets AIF\n", + "@prefix aif: .\n", + "\n", + "aif:p10 a aif:I-node ;\n", + " aif:RAedge aif:RA_p10_p9 ;\n", + " aif:text \"proposition p10\" .\n", + "\n", + "aif:p11 a aif:I-node ;\n", + " aif:CAedge aif:CA_p11_p6 ;\n", + " aif:text \"proposition p11\" .\n", + "\n", + "aif:p12 a aif:I-node ;\n", + " aif:CAedge aif:CA_p12_p9 ;\n", + " aif:text \"proposition p12\" .\n", + "\n", + "aif:p18 a aif:I-node ;\n", + " aif:CAedge aif:CA_p18_p1 ;\n", + " aif:text \"proposition p18\" .\n", + "\n", + "aif:p5 a aif:I-node ;\n", + " aif:RAedge aif:RA_p5_p4 ;\n", + " aif:text \"proposition p5\" .\n", + "\n", + "aif:CA_p11_p6 a aif:CA-node ;\n", + " \n", + "AF_t : 13 arguments, 9 attaques\n", + "AF_t+1 : 14 arguments, 10 attaques\n", + "\n", + "E_t (grounded, 8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']\n", + "E_t+1 (grounded, 8/14) : ['p10', 'p15', 'p16', 'p2', 'p4', 'p5', 'p7', 'p9']\n", + "Sanity : d(G, G) = 0 sur les trois composantes : OK\n", + "Moitie a (jours 1-15) : 13 arguments, 9 attaques | grounded 8\n", + "Moitie b (jours 16-30) : 12 arguments, 8 attaques | grounded 7\n", + "\n", + "Plancher de bruit (split moities de la periode t) :\n", + " d_noeuds = 0.077\n", + " d_att = 0.111\n", + " d_sem = 0.125\n", + "composante ecart t->t+1 plancher rapport verdict\n", + "------------------------------------------------------------------------\n", + "d_noeuds 0.200 0.077 2.6x DEPASSE\n", + "d_att 0.273 0.111 2.5x DEPASSE\n", + "d_sem 0.667 0.125 5.3x DEPASSE\n", + "Entrees dans l'extension grounded (acceptes en t+1, pas en t) : ['p15', 'p16', 'p7', 'p9']\n", + "Sorties de l'extension grounded (acceptes en t, plus en t+1) : ['p11', 'p12', 'p18', 'p8']\n", + "Lecture des cascades :\n", + " - p15->p12 (non contré) fait sortir p12 ; p12 sortant, p9 n'est plus\n", + " contré -> p9 entre ; et p9 dans l'extension défait p8 -> p7 entre\n", + " (son attaquant p8 est desormais defait).\n", + " - p8 sort : il exigeait la defaite de son attaquant p9, qui n'etait\n", + " defait QUE par p12 -- p12 sortant, p8 perd sa defense.\n", + " - p16->p11 fait sortir p11 (non contré).\n", + " - p18 n'est plus entendu en t+1 : son attaque disparait (avec son noeud).\n", + " - p1 reste dehors : son attaquant p2 n'est jamais defait (p3, seul\n", + " attaquant de p2, n'entre jamais dans l'extension -- p4 indéfaisable).\n", + "E_ctrl = ['p10', 'p11', 'p12', 'p18', 'p3', 'p5', 'p8', 'p_ctrl']\n", + "\n", + "attendu (ecrit avant) : d_sem = 0.4, d_att = 0.1\n", + "mesure : d_sem = 0.400, d_att = 0.100\n", + "\n", + "Controle positif : d_sem CONFORME, d_att CONFORME a l'attendu ecrit\n", + "p4 sorti : True | p2 sorti : True | p3 entre : True | p_ctrl entre : True\n", + "Resume de l'instrument (toute mesure d'ecart avec son plancher) :\n", + " plancher_bruit : {'d_noeuds': 0.077, 'd_att': 0.111, 'd_sem': 0.125}\n", + " ecart_inter_dates : {'d_noeuds': 0.2, 'd_att': 0.273, 'd_sem': 0.667}\n", + " controle_positif : {'d_sem_attendu': 0.4, 'd_sem_mesure': 0.4, 'd_att_attendu': 0.1, 'd_att_mesure': 0.1}\n", + " exclusions : {'t': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)'}, 't+1': {'p13': 'C1 : acte non assertif', 'p14': 'C3 : singleton (aucune relation dans la fenetre)', 'p17': 'C3 : singleton (aucune relation dans la fenetre)'}}\n", + "Lecture cumulative :\n", + " G_cumul_t : 13 arguments, 9 attaques\n", + " G_cumul_t1 : 15 arguments, 11 attaques\n", + "\n", + "Hypothese monotone sur ce corpus :\n", + " args G_cumul_t inclus dans G_cumul_t1 : True\n", + " attacks G_cumul_t inclus dans G_cumul_t1 : True\n", + " -> VERIFIEE PAR CONSTRUCTION : sur des corpus emboites, build_aif_graph\n", + " n'exclut jamais un noeud deja relie (C3 ne frappe que les singletons, et\n", + " les relations s'accumulent). Ici la monotonie est un theoreme de\n", + " l'instrument ; elle ne le resterait pas devant un corpus reel retire des enonces.\n", + "Grounded de G_cumul_t (8/13) : ['p10', 'p11', 'p12', 'p18', 'p2', 'p4', 'p5', 'p8']\n", + "Hors grounded (7) : ['p1', 'p15', 'p16', 'p3', 'p6', 'p7', 'p9']\n", + "\n", + "Retrait d'un argument DEFAIT (hors grounded) — effet mesure un par un :\n", + " retrait 'p1': d_noeuds=0.133 d_att=0.182 d_sem=0.111 d_sem BOUGE (0.111)\n", + " retrait 'p15': d_noeuds=0.067 d_att=0.091 d_sem=0.500 d_sem BOUGE (0.500)\n", + " retrait 'p16': d_noeuds=0.067 d_att=0.091 d_sem=0.200 d_sem BOUGE (0.200)\n", + " retrait 'p3': d_noeuds=0.067 d_att=0.182 d_sem=0.000 d_sem immobile\n", + " retrait 'p6': d_noeuds=0.067 d_att=0.182 d_sem=0.000 d_sem immobile\n", + " retrait 'p7': d_noeuds=0.067 d_att=0.182 d_sem=0.200 d_sem BOUGE (0.200)\n", + " retrait 'p9': d_noeuds=0.133 d_att=0.182 d_sem=0.455 d_sem BOUGE (0.455)\n", + " -> 2 retraits laissent grounded inchange ; 5 le font bouger\n", + " (un defait qui ATTAQUAIT un candidat peut, en disparaisant, le faire accepter)\n", + "\n", + "Retrait de 'p10' [ACCEPTE, dans grounded] :\n", + " d_noeuds = 0.067 d_att = 0.000 d_sem = 0.111\n", + "\n", + "Ce que l'instrument fait sous violation :\n", + " d_noeuds > 0 pour TOUT retrait : la mesure structurelle voit chaque disparition ;\n", + " d_sem ne bouge QUE si le retrait touche l'extension grounded (directement, ou via\n", + " un attaquant disparu qui libere un candidat) ;\n", + " AUCUNE des deux mesures ne dit RETRAIT vs APPARITION : le sens du mouvement n'est\n", + " pas dans l'ecart de graphes — il est dans les evenements dates du corpus. C'est\n", + " la limite explicite que l'hypothese monotone permettait de ne pas poser.\n", + "Exercice 1 a completer : fonction definie, a vous de l'appeler sur af_t / af_t1\n", + "Exercice 2 a completer : fonction definie, a vous de produire la distribution\n", + "Exercice 3 a completer : fonctions definies, a vous de produire le classement\n", + "[instrument] module importe : _dated_graphs_mod.py (Enonce, AIFGraph, build_aif_graph, to_dung_af, d_struct, d_sem, split_half)\n" + ] + } + ], + "source": [ + "# --- Reprise de l'instrument Dated_Graphs ---\n", + "# Source : Argument_Analysis_Dated_Graphs.ipynb (#13310), converti en module\n", + "# `_dated_graphs_mod.py` (meme repertoire). Aucune re-ecriture : les symboles\n", + "# de l'instrument sont importes, jamais redefinis ici.\n", + "\n", + "import importlib.util\n", + "from pathlib import Path\n", + "\n", + "# Anchrage repo-root : nbconvert execute un notebook avec cwd = son repertoire\n", + "# (papermill --cwd peut chosir un autre point de depart). On remonte jusqu'a la\n", + "# racine qui porte MyIA.AI.Notebooks/, puis on atteint le module par chemin\n", + "# canonique -- meme regle que le substrat Dated_Graphs (Path relatif racine).\n", + "_ROOT = Path.cwd()\n", + "while not (_ROOT / \"MyIA.AI.Notebooks\").is_dir() and _ROOT.parent != _ROOT:\n", + " _ROOT = _ROOT.parent\n", + "\n", + "INSTR_DIR = _ROOT / \"MyIA.AI.Notebooks\" / \"SymbolicAI\" / \"Argument_Analysis\"\n", + "PY_PATH = INSTR_DIR / \"_dated_graphs_mod.py\"\n", + "if not PY_PATH.exists():\n", + " raise RuntimeError(f\"module instrument absent : {PY_PATH}\")\n", + "\n", + "spec = importlib.util.spec_from_file_location(\"_dated_graphs_mod\", PY_PATH)\n", + "_mod = importlib.util.module_from_spec(spec)\n", + "spec.loader.exec_module(_mod)\n", + "\n", + "from _dated_graphs_mod import ( # noqa: F401,E402\n", + " Enonce, AIFGraph, build_aif_graph, to_rdf,\n", + " AF, defeats, defends, grounded, to_dung_af,\n", + " jaccard_distance, d_struct, d_sem, split_half,\n", + ")\n", + "INSTRUMENT_OK = True\n", + "print(f\"[instrument] module importe : {PY_PATH.name} \"\n", + " f\"(Enonce, AIFGraph, build_aif_graph, to_dung_af, d_struct, d_sem, split_half)\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c6a1d6c5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:19:37.839254Z", + "iopub.status.busy": "2026-09-16T17:19:37.839121Z", + "iopub.status.idle": "2026-09-16T17:19:37.841818Z", + "shell.execute_reply": "2026-09-16T17:19:37.841291Z" + } + }, + "outputs": [], + "source": [ + "# --- Garde : l'instrument est LA seule source de verite ---\n", + "# Pas de repli inline : une reimplementation locale deriverait de\n", + "# l'instrument et fausserait le renvoi [\"rebranche, pas reecrit\"].\n", + "# Si l'import a echoue, l'execution s'arrete ici, bruyamment.\n", + "\n", + "if not INSTRUMENT_OK:\n", + " raise RuntimeError(\"instrument indisponible : import _dated_graphs_mod.py echoue -- voir cellule 4\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "646d1d8b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:19:37.844184Z", + "iopub.status.busy": "2026-09-16T17:19:37.844055Z", + "iopub.status.idle": "2026-09-16T17:20:05.279407Z", + "shell.execute_reply": "2026-09-16T17:20:05.277759Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Bumble_pre: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Bumble_post: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Hinge_pre: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Hinge_post: 54 posts, 54 non vides | buckets=[9, 9, 9, 9, 9, 9]\n", + "\n", + "Total brut : 216 posts. Aucun n'est commite en memoire d'execution.\n" + ] + } + ], + "source": [ + "# --- Fetch Arctic Shift : 4 corpus (bras x fenetre), stratifie mensuellement ---\n", + "# Chaque fenetre de 6 mois est decoupee en 6 seaux ~30 jours ; on tire par seau\n", + "# un nombre borne de posts. Parametres after/before en SECONDES EPOCH\n", + "# (teste : les chaines ISO y sont mal traitees et ramenent un seul jour,\n", + "# mesurable date_min==date_max -- defaut v1 corrige). Comptes par seau imprimes.\n", + "\n", + "import urllib.request\n", + "import urllib.parse\n", + "import json\n", + "from datetime import date, timedelta\n", + "\n", + "BASE = \"https://arctic-shift.photon-reddit.com/api/posts/search\"\n", + "\n", + "WINDOWS = {\n", + " \"Bumble_pre\": (\"Bumble\", date(2023, 10, 15), date(2024, 4, 29)),\n", + " \"Bumble_post\": (\"Bumble\", date(2024, 5, 15), date(2024, 11, 15)),\n", + " \"Hinge_pre\": (\"hingeapp\", date(2023, 10, 15), date(2024, 4, 29)),\n", + " \"Hinge_post\": (\"hingeapp\", date(2024, 5, 15), date(2024, 11, 15)),\n", + "}\n", + "BUCKETS = 6\n", + "PER_BUCKET = 9 # jusqu'a ~54 posts / fenetre\n", + "BUCKET_COUNTS = {}\n", + "\n", + "\n", + "def _month_slices(a, b):\n", + " slices = []\n", + " span = (b - a).days\n", + " step = span / max(BUCKETS, 1)\n", + " for k in range(BUCKETS):\n", + " start = a + timedelta(days=int(k * step))\n", + " end = a + timedelta(days=int((k + 1) * step))\n", + " slices.append((start, end))\n", + " return slices\n", + "\n", + "\n", + "def _epoch(d):\n", + " # Pas de strftime (\"%s\") sur Windows : epoch UTC calcule arithmetiquement.\n", + " return (d - date(1970, 1, 1)).days * 86400\n", + "\n", + "\n", + "def fetch_page(sub, after, before, limit=100):\n", + " qs = urllib.parse.urlencode({\n", + " \"subreddit\": sub,\n", + " \"after\": _epoch(after),\n", + " \"before\": _epoch(before),\n", + " \"limit\": limit, \"sort\": \"desc\", \"sort_type\": \"created_utc\",\n", + " })\n", + " url = f\"{BASE}?{qs}\"\n", + " req = urllib.request.Request(url, headers={\"User-Agent\": \"observatoire-cas1/1.0\"})\n", + " with urllib.request.urlopen(req, timeout=30) as r:\n", + " body = json.load(r)\n", + " return body.get(\"data\", [])\n", + "\n", + "\n", + "def fetch_corpus(sub, win_start, win_end, per_bucket=PER_BUCKET):\n", + " rows, per_bucket_counts = [], []\n", + " for (s, e) in _month_slices(win_start, win_end):\n", + " page = fetch_page(sub, s + timedelta(days=1), e + timedelta(days=1))\n", + " rows += page[:per_bucket]\n", + " per_bucket_counts.append(len(page[:per_bucket]))\n", + " BUCKET_COUNTS[f\"{sub}_{win_start}\"] = per_bucket_counts\n", + " return rows\n", + "\n", + "\n", + "corpora = {}\n", + "for name, (sub, a, b) in WINDOWS.items():\n", + " rows = fetch_corpus(sub, a, b)\n", + " corpora[name] = rows\n", + " n_kept = sum(1 for r in rows if r.get(\"selftext\") or r.get(\"title\"))\n", + " print(f\" {name}: {len(rows)} posts, {n_kept} non vides | buckets={BUCKET_COUNTS[f'{sub}_{a}']}\")\n", + "\n", + "total = sum(len(c) for c in corpora.values())\n", + "print(f\"\\nTotal brut : {total} posts. Aucun n'est commite en memoire d'execution.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0eddb591", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:20:05.281894Z", + "iopub.status.busy": "2026-09-16T17:20:05.281571Z", + "iopub.status.idle": "2026-09-16T17:20:05.292339Z", + "shell.execute_reply": "2026-09-16T17:20:05.291254Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"Bumble_pre\": {\n", + " \"n\": 54,\n", + " \"len_moy\": 226.6,\n", + " \"date_min\": \"2023-11-16\",\n", + " \"date_max\": \"2024-04-29\"\n", + " },\n", + " \"Bumble_post\": {\n", + " \"n\": 54,\n", + " \"len_moy\": 326.9,\n", + " \"date_min\": \"2024-06-14\",\n", + " \"date_max\": \"2024-11-15\"\n", + " },\n", + " \"Hinge_pre\": {\n", + " \"n\": 54,\n", + " \"len_moy\": 240.6,\n", + " \"date_min\": \"2023-11-16\",\n", + " \"date_max\": \"2024-04-29\"\n", + " },\n", + " \"Hinge_post\": {\n", + " \"n\": 54,\n", + " \"len_moy\": 130.1,\n", + " \"date_min\": \"2024-06-14\",\n", + " \"date_max\": \"2024-11-15\"\n", + " }\n", + "}\n" + ] + } + ], + "source": [ + "# --- Agregats public-safe : pas de selftext brut, pas d'auteur ---\n", + "# Pour chaque corpus : nb de posts, longueur moyenne, etendue des dates\n", + "# (sanity de fenetre : les bornes doivent couvrir la periode pre-inscrite).\n", + "# Le selftext complet n'est JAMAIS ecrit dans le notebook committe.\n", + "\n", + "import re\n", + "import json as _json\n", + "import datetime as _dt\n", + "\n", + "\n", + "def anonymize(text):\n", + " if not text:\n", + " return \"\"\n", + " t = re.sub(r\"https?://\\S+\", \"\", text)\n", + " t = re.sub(r\"/u/\\w+|@\\w+\", \"[REDACTED]\", t)\n", + " t = t.strip()\n", + " return (t[:140] + \"...\") if len(t) > 140 else t\n", + "\n", + "\n", + "def iso(ts):\n", + " return _dt.datetime.fromtimestamp(ts, tz=_dt.timezone.utc).strftime(\"%Y-%m-%d\")\n", + "\n", + "\n", + "aggregats = {}\n", + "for name, rows in corpora.items():\n", + " lengths = [len((r.get(\"selftext\") or \"\") + (r.get(\"title\") or \"\")) for r in rows]\n", + " dates = [r[\"created_utc\"] for r in rows if r.get(\"created_utc\")]\n", + " verbatims = [anonymize(r.get(\"selftext\") or r.get(\"title\", \"\"))\n", + " for r in rows if (r.get(\"selftext\") or r.get(\"title\"))]\n", + " verbatims = [v for v in verbatims if v][:5]\n", + " aggregats[name] = {\n", + " \"n\": len(rows),\n", + " \"len_moy\": round(sum(lengths) / max(len(lengths), 1), 1),\n", + " \"date_min\": iso(min(dates)) if dates else None,\n", + " \"date_max\": iso(max(dates)) if dates else None,\n", + " \"verbatims\": verbatims,\n", + " }\n", + "\n", + "meta = {k: {kk: vv for kk, vv in v.items() if kk != \"verbatims\"}\n", + " for k, v in aggregats.items()}\n", + "print(_json.dumps(meta, indent=2, ensure_ascii=False))\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "12a98a2a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:20:05.294712Z", + "iopub.status.busy": "2026-09-16T17:20:05.294362Z", + "iopub.status.idle": "2026-09-16T17:29:59.517916Z", + "shell.execute_reply": "2026-09-16T17:29:59.514583Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " extraction : Bumble_pre (54 textes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 10/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 20/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 30/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 40/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 50/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " extraction : Bumble_post (54 textes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 10/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 20/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 30/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 40/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 50/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " extraction : Hinge_pre (54 textes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 10/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 20/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 30/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 40/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 50/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " extraction : Hinge_post (54 textes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 10/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 20/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 30/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 40/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ... 50/54\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Total classifications : 216 | echecs apres retries : 0\n" + ] + } + ], + "source": [ + "# --- Extraction LLM Ollama : categories + oppositions, ECHANTILLON COMPLET ---\n", + "# Modele epingle : qwen2.5:7b-instruct-q4_K_M (4.7 Go).\n", + "# Temperature 0, prompt + schema JSON e pingles. ~50 textes / cellule,\n", + "# 4 cellules = ~200 classifications (duree 15-40 min, progression toutes les\n", + "# 10). Grille de lecture en bas de cellule.\n", + "\n", + "import urllib.request\n", + "import json\n", + "\n", + "OLLAMA = \"http://localhost:11434/api/generate\"\n", + "MODEL = \"qwen2.5:7b-instruct-q4_K_M\"\n", + "\n", + "CATEGORIES = [\n", + " \"INIT_NORM_W\", # la femme doit initier\n", + " \"INIT_NORM_ANY\", # n'importe qui peut initier\n", + " \"INIT_COST\", # cout/burden de l'initiation\n", + " \"BLAME\", # blame d'un genre pour la non-initiation\n", + " \"MATCH_DECAY\", # matchs meurent sans premier message\n", + " \"OPENING_MOVE_REACT\", # reaction au changement nomme\n", + "]\n", + "\n", + "PROMPT_TEMPLATE = \"\"\"Classifie le post ci-dessous. Renvoie UNIQUEMENT un objet JSON :\n", + "{{ \"cats\": [categories portees, 0..n], \"opposes\": [categories AUXQUELLES le post s'oppose, 0..n] }}.\n", + "\n", + "Categories possibles :\n", + "{cats}\n", + "\n", + "Regle : \"opposes\" ne contient que des categories que le post contredit ou refute\n", + "explicitement. Pas d'inference : absence de mention = pas d'opposition.\n", + "\n", + "Post :\n", + "{post}\n", + "\"\"\"\n", + "\n", + "\n", + "def classify(text, retries=3):\n", + " prompt = PROMPT_TEMPLATE.format(cats=\", \".join(CATEGORIES), post=text[:600])\n", + " payload = json.dumps({\n", + " \"model\": MODEL, \"prompt\": prompt, \"stream\": False,\n", + " \"options\": {\"temperature\": 0.0, \"num_predict\": 250},\n", + " }).encode(\"utf-8\")\n", + " req = urllib.request.Request(OLLAMA, data=payload,\n", + " headers={\"Content-Type\": \"application/json\"})\n", + " for attempt in range(retries):\n", + " try:\n", + " with urllib.request.urlopen(req, timeout=180) as r:\n", + " resp = json.load(r)\n", + " txt = resp.get(\"response\", \"\").strip()\n", + " i, j = txt.find(\"{\"), txt.rfind(\"}\")\n", + " if i >= 0 and j > i:\n", + " obj = json.loads(txt[i:j + 1])\n", + " obj.setdefault(\"cats\", [])\n", + " obj.setdefault(\"opposes\", [])\n", + " valid = [c for c in obj[\"cats\"] if c in CATEGORIES]\n", + " valid_o = [c for c in obj[\"opposes\"] if c in CATEGORIES]\n", + " return {\"cats\": valid, \"opposes\": valid_o}\n", + " except Exception:\n", + " continue\n", + " return {\"cats\": [], \"opposes\": [], \"error\": \"3 echecs\"}\n", + "\n", + "\n", + "classifications = {}\n", + "fails = 0\n", + "for name, rows in corpora.items():\n", + " eligible = [r for r in rows if (r.get(\"selftext\") or r.get(\"title\"))]\n", + " classifications[name] = []\n", + " print(f\"\\n extraction : {name} ({len(eligible)} textes)\")\n", + " for k, r in enumerate(eligible):\n", + " text = r.get(\"selftext\") or r.get(\"title\", \"\")\n", + " out = classify(text)\n", + " if \"error\" in out:\n", + " fails += 1\n", + " classifications[name].append({\"id\": r.get(\"id\"), **out})\n", + " if k % 10 == 9:\n", + " print(f\" ... {k + 1}/{len(eligible)}\")\n", + "\n", + "total = sum(len(v) for v in classifications.values())\n", + "print(f\"\\nTotal classifications : {total} | echecs apres retries : {fails}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "d788203a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:29:59.525435Z", + "iopub.status.busy": "2026-09-16T17:29:59.524686Z", + "iopub.status.idle": "2026-09-16T17:30:02.108333Z", + "shell.execute_reply": "2026-09-16T17:30:02.104860Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Distribution (count de textes portant la categorie) :\n", + " Bumble_pre Bumble_post Hinge_pre Hinge_post\n", + "INIT_NORM_W 46 43 48 47\n", + "INIT_NORM_ANY 6 8 5 6\n", + "INIT_COST 2 8 2 1\n", + "BLAME 0 2 0 0\n", + "MATCH_DECAY 3 3 2 0\n", + "OPENING_MOVE_REACT 0 0 0 0\n", + "\n", + "Delta post-pre traite (Bumble) : {'INIT_NORM_W': -3, 'INIT_NORM_ANY': 2, 'INIT_COST': 6, 'BLAME': 2, 'MATCH_DECAY': 0, 'OPENING_MOVE_REACT': 0}\n", + "Delta post-pre controle (Hinge) : {'INIT_NORM_W': -1, 'INIT_NORM_ANY': 1, 'INIT_COST': -1, 'BLAME': 0, 'MATCH_DECAY': -2, 'OPENING_MOVE_REACT': 0}\n", + "Delta DiD (traite - controle) : {'INIT_NORM_W': -2, 'INIT_NORM_ANY': 1, 'INIT_COST': 7, 'BLAME': 2, 'MATCH_DECAY': 2, 'OPENING_MOVE_REACT': 0}\n" + ] + } + ], + "source": [ + "# --- Distribution des categories par cellule ---\n", + "# Comptage : combien de textes portent >= 1 occurrence de la categorie.\n", + "# Multilabel : un texte peut porter plusieurs categories.\n", + "\n", + "from collections import Counter\n", + "import pandas as pd\n", + "\n", + "dist = {}\n", + "for name, rows in classifications.items():\n", + " c = Counter()\n", + " for r in rows:\n", + " for cat in r.get(\"cats\", []):\n", + " c[cat] += 1\n", + " dist[name] = dict(c)\n", + "\n", + "df_dist = pd.DataFrame(dist, index=CATEGORIES).fillna(0).astype(int)\n", + "print(\"Distribution (count de textes portant la categorie) :\")\n", + "print(df_dist)\n", + "\n", + "delta_t = df_dist[\"Bumble_post\"] - df_dist[\"Bumble_pre\"]\n", + "delta_c = df_dist[\"Hinge_post\"] - df_dist[\"Hinge_pre\"]\n", + "delta_T = df_dist[\"Bumble_post\"] - df_dist[\"Hinge_post\"]\n", + "delta_B = df_dist[\"Bumble_pre\"] - df_dist[\"Hinge_pre\"]\n", + "did = delta_T - delta_B\n", + "print(\"\\nDelta post-pre traite (Bumble) :\", delta_t.to_dict())\n", + "print(\"Delta post-pre controle (Hinge) :\", delta_c.to_dict())\n", + "print(\"Delta DiD (traite - controle) :\", did.to_dict())\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b7ade0d0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:30:02.116399Z", + "iopub.status.busy": "2026-09-16T17:30:02.115523Z", + "iopub.status.idle": "2026-09-16T17:30:02.187394Z", + "shell.execute_reply": "2026-09-16T17:30:02.183088Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " [texte] Bumble_pre : 54 I-nodes, 0 CA, 0 exclus (C1)\n", + " [texte] Bumble_post : 54 I-nodes, 63 CA, 0 exclus (C1)\n", + " [texte] Hinge_pre : 54 I-nodes, 7 CA, 0 exclus (C1)\n", + " [texte] Hinge_post : 54 I-nodes, 53 CA, 0 exclus (C1)\n", + "\n", + " [texte] d_struct(Bumble)=1.000/1.000 d_sem=1.000 | d_struct(controle)=1.000/1.000 d_sem=1.000\n", + " SATURATION : textes uniques par fenetre -> props disjointes -> d_struct=1.0 par construction. Niveau textuel non discriminant ici ; non utilise pour le verdict (transition v1->v2, cf. §6).\n", + " [position] Bumble_pre : 4 I-nodes, 0 CA, 0 exclus (C1)\n", + " [position] Bumble_post : 5 I-nodes, 9 CA, 0 exclus (C1)\n", + " [position] Hinge_pre : 4 I-nodes, 3 CA, 0 exclus (C1)\n", + " [position] Hinge_post : 3 I-nodes, 2 CA, 0 exclus (C1)\n", + "\n", + " [position] d_struct(Bumble) : noeuds=0.200 attaques=1.000 d_sem=1.000\n", + " [position] d_struct(controle) : noeuds=0.250 attaques=0.750 d_sem=1.000\n", + " DiD(noeuds) = -0.050\n", + " DiD(attaques) = 0.250\n", + " DiD(sem) = 0.000\n" + ] + } + ], + "source": [ + "# --- Mesure structurelle via l'instrument (module _dated_graphs_mod) ---\n", + "# Unite d'analyse PRIMAIRE : position argumentative (la categorie portee par\n", + "# un texte). Chaque (texte, categorie portee) est un enonce assertif ; un lien\n", + "# CA (X -> Y) existe des qu'un texte porte X et s'oppose explicitement a Y.\n", + "# Projection Dung (to_dung_af) puis d_struct/d_sem de l'instrument, compares\n", + "# en DiD traite - controle.\n", + "# Niveau TEXTUEL : mesure puis saturation -- les textes sont uniques (aucun\n", + "# repost), donc props disjointes entre fenetres et d_struct = 1.0 par\n", + "# construction. Imprime comme saturation mesuree ; non utilise pour le verdict.\n", + "# ADAPTATION DOCUMENTEE du critere C3 l'instrument #13310 (build_aif_graph\n", + "# exclut les propositions sans relation \"singleton\", concu pour des corpus\n", + "# denses a propositions repetees). Ici l'unite est la CATEGORIE (position) :\n", + "# exclure les singletons viderait la mesure (mesure v1 : 0 I-nodes cote\n", + "# controle, mesuree puis imprimee). C3 neutralise et imprime comme divergence\n", + "# assumee ; seules les exclusions C1 subsistent.\n", + "\n", + "import hashlib\n", + "\n", + "\n", + "def text_to_prop(text):\n", + " h = hashlib.md5(text.encode(\"utf-8\")).hexdigest()[:6]\n", + " return f\"p{h}\"\n", + "\n", + "\n", + "def build_enonces(rows, unit, day_start):\n", + " \"\"\"Enonces AIF pour une fenetre.\n", + " unit=\"cat\" (primaire) : un enonce assertif par (texte, categorie portee),\n", + " relations CA = categories explicitement opposees par ce texte.\n", + " unit=\"text\" (v1, sature): un enonce par texte (prop = hash), relations CA\n", + " vers les propositions portant une categorie opposee.\n", + " \"\"\"\n", + " cat_to_props = {}\n", + " for r in rows:\n", + " for cat in r.get(\"cats\", []):\n", + " cat_to_props.setdefault(cat, set()).add(r[\"id\"])\n", + " enonces = []\n", + " for i, r in enumerate(rows):\n", + " day = day_start + i\n", + " opposes = set(r.get(\"opposes\", []))\n", + " cats = r.get(\"cats\", []) if unit == \"cat\" else [None]\n", + " for cat in cats:\n", + " if unit == \"cat\":\n", + " prop = cat\n", + " rels = [(\"CA\", o) for o in opposes if o != cat]\n", + " else:\n", + " prop = text_to_prop(r.get(\"verbatim\") or r.get(\"id\", \"\"))\n", + " rels = []\n", + " for o in opposes:\n", + " for cible_id in cat_to_props.get(o, ()):\n", + " if cible_id != r[\"id\"]:\n", + " rels.append((\"CA\", text_to_prop(cible_id)))\n", + " enonces.append(Enonce(day, prop, \"assertif\", tuple(sorted(set(rels)))))\n", + " return enonces\n", + "\n", + "\n", + "def build_graph_no_c3(enonces):\n", + " # build_aif_graph de l'instrument avec C3 desactive (adaptation\n", + " # documentee ci-dessus) : seules les exclusions C1 subsistent.\n", + " g = AIFGraph()\n", + " vues = set()\n", + " for e in enonces:\n", + " if e.prop in vues:\n", + " continue\n", + " vues.add(e.prop)\n", + " if e.acte != \"assertif\":\n", + " g.excluded.append((e.prop, \"C1 : acte non assertif\"))\n", + " exclus = {p for p, _ in g.excluded}\n", + " for p in sorted(vues - exclus):\n", + " g.i_nodes[p] = f\"position {p}\"\n", + " for e in enonces:\n", + " for scheme, cible in e.relations:\n", + " g.scheme_nodes[f\"{scheme}_{e.prop}_{cible}\"] = (scheme, e.prop, cible)\n", + " return g\n", + "\n", + "\n", + "# --- Niveau TEXTUEL : saturation mesuree (regression v1, C3 neutralise) ---\n", + "g_text = {}\n", + "af_text = {}\n", + "for name in (\"Bumble_pre\", \"Bumble_post\", \"Hinge_pre\", \"Hinge_post\"):\n", + " period = \"post\" if \"_post\" in name else \"pre\"\n", + " enonces = build_enonces(classifications[name], \"text\", 1 if period == \"pre\" else 200)\n", + " gg = build_graph_no_c3(enonces)\n", + " g_text[name] = gg\n", + " af_text[name] = to_dung_af(gg)\n", + " print(f\" [texte] {name} : {len(gg.i_nodes)} I-nodes, {len(gg.scheme_nodes)} CA, \"\n", + " f\"{len(gg.excluded)} exclus (C1)\")\n", + "\n", + "db_t = (d_struct(af_text[\"Bumble_pre\"], af_text[\"Bumble_post\"]),\n", + " d_sem(af_text[\"Bumble_pre\"], af_text[\"Bumble_post\"]))\n", + "dh_t = (d_struct(af_text[\"Hinge_pre\"], af_text[\"Hinge_post\"]),\n", + " d_sem(af_text[\"Hinge_pre\"], af_text[\"Hinge_post\"]))\n", + "print(\"\\n [texte] d_struct(Bumble)={:.3f}/{:.3f} d_sem={:.3f} | \"\n", + " \"d_struct(controle)={:.3f}/{:.3f} d_sem={:.3f}\".format(\n", + " db_t[0][0], db_t[0][1], db_t[1], dh_t[0][0], dh_t[0][1], dh_t[1]))\n", + "print(\" SATURATION : textes uniques par fenetre -> props disjointes -> \"\n", + " \"d_struct=1.0 par construction. Niveau textuel non discriminant ici ; \"\n", + " \"non utilise pour le verdict (transition v1->v2, cf. §6).\")\n", + "\n", + "# --- Niveau POSITION (categorie portee) : mesure primaire ---\n", + "g_cat = {}\n", + "af_cat = {}\n", + "for name in (\"Bumble_pre\", \"Bumble_post\", \"Hinge_pre\", \"Hinge_post\"):\n", + " period = \"post\" if \"_post\" in name else \"pre\"\n", + " enonces = build_enonces(classifications[name], \"cat\", 1 if period == \"pre\" else 200)\n", + " gg = build_graph_no_c3(enonces)\n", + " g_cat[name] = gg\n", + " af_cat[name] = to_dung_af(gg)\n", + " print(f\" [position] {name} : {len(gg.i_nodes)} I-nodes, {len(gg.scheme_nodes)} CA, \"\n", + " f\"{len(gg.excluded)} exclus (C1)\")\n", + "\n", + "db = (d_struct(af_cat[\"Bumble_pre\"], af_cat[\"Bumble_post\"]),\n", + " d_sem(af_cat[\"Bumble_pre\"], af_cat[\"Bumble_post\"]))\n", + "dh = (d_struct(af_cat[\"Hinge_pre\"], af_cat[\"Hinge_post\"]),\n", + " d_sem(af_cat[\"Hinge_pre\"], af_cat[\"Hinge_post\"]))\n", + "delta_n = db[0][0] - dh[0][0]\n", + "delta_a = db[0][1] - dh[0][1]\n", + "delta_s = db[1] - dh[1]\n", + "print(f\"\\n [position] d_struct(Bumble) : noeuds={db[0][0]:.3f} attaques={db[0][1]:.3f} d_sem={db[1]:.3f}\")\n", + "print(f\" [position] d_struct(controle) : noeuds={dh[0][0]:.3f} attaques={dh[0][1]:.3f} d_sem={dh[1]:.3f}\")\n", + "print(f\" DiD(noeuds) = {delta_n:.3f}\")\n", + "print(f\" DiD(attaques) = {delta_a:.3f}\")\n", + "print(f\" DiD(sem) = {delta_s:.3f}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "bf5d19fb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-16T17:30:02.196229Z", + "iopub.status.busy": "2026-09-16T17:30:02.194568Z", + "iopub.status.idle": "2026-09-16T17:30:02.226806Z", + "shell.execute_reply": "2026-09-16T17:30:02.223208Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plancher noeuds : Bumble pre=0.000 Hinge pre=0.250 moyen=0.125\n", + "Plancher attaques : Bumble pre=0.000 Hinge pre=1.000 moyen=0.500\n", + "Plancher sem : Bumble pre=0.000 Hinge pre=0.000\n", + "\n", + "DiD observe (noeuds) -0.050 ; au-dessus plancher : -0.175\n", + "DiD observe (attaques) +0.250 ; au-dessus plancher : -0.250\n", + "\n", + "VERDICT : INCONCLUSIVE\n" + ] + } + ], + "source": [ + "# --- Plancher de bruit : split-half CHRONOLOGIQUE (instrument) ---\n", + "# Le split_half de l'instrument decoupe chaque periode en deux moities par\n", + "# ordre d'arrivee (premiere / seconde), sans seed : une seule realisation\n", + "# par bras, fidele a #13310. Memes regles que la cellule 10 : unite POSITION,\n", + "# C3 neutralise (adaptation documentee). Le DiD observe doit etre lu contre\n", + "# ce plancher.\n", + "\n", + "def split_and_d_struct(enonces):\n", + " moitie_a, moitie_b = split_half(enonces)\n", + " ga, gb = build_graph_no_c3(moitie_a), build_graph_no_c3(moitie_b)\n", + " aa, ab = to_dung_af(ga), to_dung_af(gb)\n", + " return d_struct(aa, ab), d_sem(aa, ab)\n", + "\n", + "\n", + "floor_b, floor_sb = split_and_d_struct(\n", + " build_enonces(classifications[\"Bumble_pre\"], \"cat\", 1))\n", + "floor_h, floor_sh = split_and_d_struct(\n", + " build_enonces(classifications[\"Hinge_pre\"], \"cat\", 1))\n", + "\n", + "floor_n = (floor_b[0] + floor_h[0]) / 2\n", + "floor_a = (floor_b[1] + floor_h[1]) / 2\n", + "print(f\"Plancher noeuds : Bumble pre={floor_b[0]:.3f} Hinge pre={floor_h[0]:.3f} moyen={floor_n:.3f}\")\n", + "print(f\"Plancher attaques : Bumble pre={floor_b[1]:.3f} Hinge pre={floor_h[1]:.3f} moyen={floor_a:.3f}\")\n", + "print(f\"Plancher sem : Bumble pre={floor_sb:.3f} Hinge pre={floor_sh:.3f}\")\n", + "\n", + "above_floor_n = delta_n - floor_n\n", + "above_floor_a = delta_a - floor_a\n", + "print(f\"\\nDiD observe (noeuds) {delta_n:+.3f} ; au-dessus plancher : {above_floor_n:+.3f}\")\n", + "print(f\"DiD observe (attaques) {delta_a:+.3f} ; au-dessus plancher : {above_floor_a:+.3f}\")\n", + "\n", + "VERDICT = \"INCONCLUSIVE\"\n", + "REASONS = []\n", + "if above_floor_n > 0.05:\n", + " REASONS.append(\"DiD(noeuds) > plancher + 0.05\")\n", + "if above_floor_a > 0.05:\n", + " REASONS.append(\"DiD(attaques) > plancher + 0.05\")\n", + "\n", + "h1_signed = (\n", + " delta_t.get(\"INIT_NORM_ANY\", 0) > 0\n", + " and delta_t.get(\"OPENING_MOVE_REACT\", 0) > 0\n", + " and delta_t.get(\"INIT_NORM_W\", 0) <= 0\n", + ")\n", + "if h1_signed:\n", + " REASONS.append(\"signes delta_traite coherents H1 \"\n", + " \"(INIT_NORM_ANY+, OPENING_MOVE_REACT+, INIT_NORM_W-)\")\n", + "\n", + "if above_floor_n > 0.05 and above_floor_a > 0.05 and h1_signed:\n", + " VERDICT = \"EXPLORATION\"\n", + "elif above_floor_n <= 0.05 and above_floor_a <= 0.05:\n", + " VERDICT = \"INCONCLUSIVE\"\n", + "else:\n", + " VERDICT = \"TRIANGULATION\"\n", + "\n", + "print(f\"\\nVERDICT : {VERDICT}\")\n", + "for r in REASONS:\n", + " print(f\" raison : {r}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "91be0b67", + "metadata": {}, + "source": [ + "## 4. Verdict et plafond de preuve\n", + "\n", + "À la fin du notebook, la cellule précédente pose le verdict sur quatre observations conjointes :\n", + "\n", + "1. **Plancher split-half** : split chronologique (instrument) sur la fenêtre *pré* de chaque bras — unité position (catégorie argumentative).\n", + "2. **DiD(noeuds)** : `Δ_struct(Bumble) − Δ_struct(contrôle)`.\n", + "3. **DiD(attaques)** : idem sur les schemes CA.\n", + "4. **Signes H₁** : direction signée par catégorie (cohérence avec le prédict ).\n", + "\n", + "**Verdict** : `EXPLORATION`, `TRIANGULATION` ou `INCONCLUSIVE`. **Jamais** plus haut qu'associatif (barreau 1) ; barreau 2 atteignable seulement si tous les critères structurels sont satisfaits. **Barreau 3 récusé par construction** (#13309 §1).\n", + "\n", + "## 5. Biais de sélection et plafond de barreau\n", + "\n", + "r/Bumble sur-représente les utilisateurs investis et les événements saillants. Le **biais de saillance** pousse vers H₁ : la rupture elle-même fabrique des posts `OPENING_MOVE_REACT`. Le bras contrôle (r/hingeapp) n'absorbe pas ce biais car il ne porte **aucune rupture** d'initiation sur la fenêtre. Le contrôle DiD reste imparfait : il absorbe le biais de plateforme (Reddit) et de fenêtre, pas le biais de sujet.\n", + "\n", + "**Plafond revendiqué d'avance** : barreau associatif. **Quasi-expérimental** inatteignable avec ce corpus (manquent : utilisateurs silencieux, résiliés, hors-app, non-anglophones).\n", + "\n", + "## 6. Limites assumées et extensions futures\n", + "\n", + "- **Volume** : ~50 textes / cellule (stratification mensuelle effective en 6 seaux, comptes par seau imprimés). Suffisant pour un premier signal, **insuffisant** pour une robustesse statistique complète : l'extension à ~200-500/cellule est une issue dédiée.\n", + "- **Unité d'analyse** : la **position argumentative** (catégorie portée) est l'unité primaire — c'est elle que déplace une rupture de norme. Le niveau **textuel** sature : textes uniques par fenêtre → propositions disjointes → `d_struct = 1.0` par construction (mesuré et imprimé, cellule 10). La mesure v1 ajoutait le critère C3 (singletons exclus) : toutes les fenêtres se vidaient (0 I-nodes, mesuré) et le verdict TRIANGULATION imprimé était un artefact ; v2 neutralise C3 — divergence unique, documentée et imprimée.\n", + "- **Instrument rebranché par import** (`_dated_graphs_mod.py`, conversion officielle de #13310) : `d_struct`/`d_sem` sur projection Dung, `split_half` chronologique — à l'exception de C3 (documentée ci-dessus), aucune réécriture.\n", + "- **Liens CA = oppositions explicites** (champ `opposes` du prompt LLM épinglé) : pas d'heuristique de taille sur les catégories.\n", + "- **Modèle local Ollama** : ré-exécution déterministe (température 0, prompt épinglé) ; modèle `qwen2.5:7b-instruct-q4_K_M` épinglé par nom, toute mise à jour = PR dédiée.\n" + ] + }, + { + "cell_type": "markdown", + "id": "98b9e68d", + "metadata": {}, + "source": [ + "## 7. Conclusion\n", + "\n", + "Premier cas de l'Observatoire livré. **Instrument Dated_Graphs rebranché, pas réécrit** (à l'exception de la neutralisation C3, divergence unique assumée et documentée cellule 10 + amendement de pré-inscription n°2 — l'unité position rend les « singletons » structurels). **Rupture datée, fenêtres symétriques, bras contrôle intact**.\n", + "\n", + "**Verdict mesuré (v2, 2026-09-16) : INCONCLUSIVE** — DiD(noeuds) = −0.050 pour un plancher split-half de 0.125, DiD(attaques) = +0.250 pour un plancher de 0.500 : aucun des deux ne dépasse plancher + 0.05, et la signature `OPENING_MOVE_REACT` est absente du corpus (0 posts dans les deux bras). Le niveau textuel sature (textes uniques → d_struct = 1.0 par construction, mesuré) ; le signal positionnel est sous le plancher de bruit. La rupture *a été* datée et mesurée ; la structure argumentative ne bouge pas au-delà du bruit de la fenêtre — c'est un résultat négatif net, cohérent avec la prudence du barreau associatif.\n", + "\n", + "Voir aussi :\n", + "\n", + "- [`Argument_Analysis_Dated_Graphs.ipynb`](Argument_Analysis_Dated_Graphs.ipynb) (#13310, l'instrument)\n", + "- Issue #13303 (EPIC Observatoire)\n", + "- Issue #13309 (protocole de preuve)\n", + "- Commentaire d'inscription #16410 (pré-inscription datée, axes signés) + amendements v1→v2 de la pré-inscription\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis/_dated_graphs_mod.py b/MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis/_dated_graphs_mod.py new file mode 100644 index 0000000000..0fb0ce57d9 --- /dev/null +++ b/MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis/_dated_graphs_mod.py @@ -0,0 +1,789 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Graphes d'argumentation datés — l'instrument $G_t^{arg} \to G_{t+1}^{arg}$ +# +# L'Observatoire des formes relationnelles (Epic [#13303](https://github.com/jsboige/CoursIA/issues/13303)) vise la chaîne : +# +# $$L_t \to L_{t+1} \;\text{(catégories lexicales)} \quad\Longrightarrow\quad A_t \to A_{t+1} \;\text{(catégories argumentatives)} \quad\Longmapsto\quad G_t^{arg} \to G_{t+1}^{arg} \;\text{(graphe d'argumentation)}$$ +# +# Ce notebook construit **l'instrument du dernier étage** : produire, depuis un corpus daté, une suite de +# graphes d'argumentation comparables, et une **mesure d'écart** entre deux dates. Il ne mesure **aucun +# phénomène social** — il fournit l'aiguille (issue [#13310](https://github.com/jsboige/CoursIA/issues/13310)). +# +# **Substrat — s'y brancher, ne pas le refaire :** +# +# - [Argument_Analysis_Ontology_AIF.ipynb](Argument_Analysis_Ontology_AIF.ipynb) : l'ontologie AIF, schéma de nœuds/liens dans lequel un graphe daté doit s'exprimer ; +# - [Argument_Analysis_Dung_AF_Semantics.ipynb](Argument_Analysis_Dung_AF_Semantics.ipynb) : les sémantiques de Dung, ce qu'« être accepté » veut dire à une date donnée ; +# - [Argument_Analysis_Ranking_Semantics.ipynb](Argument_Analysis_Ranking_Semantics.ipynb) : les sémantiques graduelles, utiles quand l'acceptation binaire écrase la variation (exercice 3). +# +# **Le piège central** : deux graphes construits depuis deux échantillons différents diffèrent *toujours*. +# Une mesure d'écart $d(G_t, G_{t+1}) > 0$ ne dit donc rien tant qu'on ne connaît pas l'écart produit par +# le **bruit d'échantillonnage seul**. D'où l'exigence non négociable de ce notebook : tout écart inter-dates +# est publié **avec son plancher de bruit** (contrôle négatif), et l'instrument est validé sur un changement +# **connu par construction** (contrôle positif). +# +# **Plan :** corpus daté synthétique → critère d'inclusion → graphe AIF conforme → cadre de Dung → +# deux mesures d'écart de familles différentes → contrôle négatif → écart inter-dates → contrôle positif → exercices. +# + +# ## 1. Le corpus daté — format d'entrée +# +# Un corpus daté est une liste d'**énoncés**, chacun portant : +# +# - une **date** (ici : un jour, entier — la granularité réelle sera fournie par le protocole [#13309](https://github.com/jsboige/CoursIA/issues/13309)) ; +# - une **proposition** identifiée par son contenu (`p1`, `p2`, …) — deux énoncés à des dates différentes peuvent **ré-énoncer la même proposition** (c'est le mécanisme qui rend deux fenêtres comparables) ; +# - un **acte de langage** (`assertif` ou `question`) ; +# - des **relations** exprimées vers d'autres propositions : `RA` (*rule application*, support inférentiel) ou `CA` (*conflict application*, attaque). +# +# Le corpus ci-dessous est **entièrement synthétique** : un débat fictif sur la tarification de l'eau. +# Période $t$ = jours 1–30, période $t+1$ = jours 31–60. La plupart des relations sont **ré-exprimées** +# d'une moitié à l'autre (culture de restatement, comme dans un vrai débat qui se rejoue) — c'est ce qui +# garde le plancher de bruit (section 6) raisonnable. Aucun corpus réel ici : l'instrument se valide +# sur synthétique ; l'application réelle attend le protocole #13309. +# + +# In[1]: + + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass(frozen=True) +class Enonce: + """Un enonce date : instantiation d'une proposition, portant ses relations.""" + jour: int + prop: str + acte: str # "assertif" ou "question" + relations: tuple = () # couples (scheme, prop_cible), scheme in {"RA", "CA"} + + +# --- Periode t : jours 1-30 (30 enonces, deux moities de 15) --- +corpus_t = [ + # premiere moitie (jours 1-15) + Enonce(1, "p1", "assertif"), + Enonce(2, "p2", "assertif", (("CA", "p1"),)), + Enonce(3, "p3", "assertif", (("CA", "p2"),)), + Enonce(4, "p4", "assertif", (("CA", "p3"),)), + Enonce(5, "p5", "assertif", (("RA", "p4"),)), + Enonce(6, "p6", "assertif"), + Enonce(7, "p7", "assertif", (("CA", "p6"),)), + Enonce(8, "p8", "assertif", (("CA", "p7"),)), + Enonce(9, "p9", "assertif", (("CA", "p8"),)), + Enonce(10, "p11", "assertif", (("CA", "p6"),)), + Enonce(11, "p10", "assertif", (("RA", "p9"),)), + Enonce(12, "p13", "question"), # exclu par C1 (non assertif) + Enonce(13, "p18", "assertif", (("CA", "p1"),)), # attaquant ephemere : non re-entendu ensuite + Enonce(14, "p12", "assertif", (("CA", "p9"),)), + Enonce(15, "p1", "assertif"), + # seconde moitie (jours 16-30) + Enonce(16, "p2", "assertif", (("CA", "p1"),)), + Enonce(17, "p3", "assertif", (("CA", "p2"),)), + Enonce(18, "p7", "assertif", (("CA", "p6"),)), + Enonce(19, "p8", "assertif", (("CA", "p7"),)), + Enonce(20, "p12", "assertif", (("CA", "p9"),)), + Enonce(21, "p11", "assertif", (("CA", "p6"),)), + Enonce(22, "p4", "assertif", (("CA", "p3"),)), + Enonce(23, "p5", "assertif", (("RA", "p4"),)), + Enonce(24, "p14", "assertif"), # exclu par C3 (aucune relation) + Enonce(25, "p9", "assertif", (("CA", "p8"),)), + Enonce(26, "p6", "assertif"), + Enonce(27, "p10", "assertif", (("RA", "p9"),)), + Enonce(28, "p12", "assertif", (("CA", "p9"),)), + Enonce(29, "p1", "assertif"), + Enonce(30, "p13", "question"), # re-exclu par C1 +] + +# --- Periode t+1 : jours 31-60 (16 enonces) --- +corpus_t1 = [ + Enonce(31, "p15", "assertif", (("CA", "p12"),)), # nouvelle attaque + Enonce(32, "p16", "assertif", (("CA", "p11"),)), # nouvelle attaque + Enonce(33, "p2", "assertif", (("CA", "p1"),)), + Enonce(34, "p3", "assertif", (("CA", "p2"),)), + Enonce(35, "p4", "assertif", (("CA", "p3"),)), + Enonce(36, "p7", "assertif", (("CA", "p6"),)), + Enonce(37, "p8", "assertif", (("CA", "p7"),)), + Enonce(38, "p9", "assertif", (("CA", "p8"),)), + Enonce(39, "p11", "assertif", (("CA", "p6"),)), + Enonce(40, "p12", "assertif", (("CA", "p9"),)), + Enonce(41, "p5", "assertif", (("RA", "p4"),)), + Enonce(42, "p10", "assertif", (("RA", "p9"),)), + Enonce(43, "p13", "question"), # exclu par C1 + Enonce(44, "p14", "assertif"), # re-exclu par C3 + Enonce(45, "p17", "assertif"), # nouvel orphelin : exclu par C3 + Enonce(46, "p1", "assertif"), # p18, lui, n'est plus entendu +] + +for nom, corpus in [("periode t (jours 1-30)", corpus_t), ("periode t+1 (jours 31-60)", corpus_t1)]: + n_assert = sum(1 for e in corpus if e.acte == "assertif") + n_q = len(corpus) - n_assert + n_rel = sum(len(e.relations) for e in corpus) + print(f"{nom} : {len(corpus)} enonces ({n_assert} assertifs, {n_q} questions), {n_rel} relations exprimees") + +print("\nApercu des 5 premiers enonces de la periode t :") +for e in corpus_t[:5]: + rel = ", ".join(f"{s}->{c}" for s, c in e.relations) or "(aucune)" + print(f" jour {e.jour:>2} | {e.prop:>4} | {e.acte:>8} | {rel}") + + +# ## 2. Critère d'inclusion — ce qui devient un nœud +# +# **Critère (écrit, appliqué mécaniquement)** — un énoncé $E$ de la fenêtre contribue un nœud-I au graphe $G$ **si et seulement si** : +# +# - **(C1)** son acte de langage est **assertif** (une question ne prend pas position : elle n'argumente pas) ; +# - **(C2)** son contenu propositionnel est **identifiable** — dans notre format, porté par un `prop` non vide (pas d'énoncé purement phatique ou d'élipse sans contenu) ; +# - **(C3)** il **entretient au moins une relation** RA ou CA — exprimée par lui **ou vers lui** — avec une autre proposition présente dans la même fenêtre. +# +# Un énoncé qui échoue à C3 est un **singleton** : il ne structure pas le débat, il en est exclu — mais +# **son exclusion est comptée et publiée** (le taux d'exclusion fait partie de la sortie : un débat qui +# devient une suite de monologues se voit dans ce taux avant de se voir dans le graphe). +# +# Les relations exprimées deviennent des **nœuds de scheme** AIF : un `RA-node` par lien de support, +# un `CA-node` par lien de conflit. Le graphe produit est donc bipartite au sens AIF : les nœuds-I +# portent le contenu, les nœuds de scheme portent les liens — et non des arêtes directes entre contenus. +# + +# In[2]: + + +@dataclass +class AIFGraph: + """Graphe AIF d'une fenetre : noeuds-I (contenus) + noeuds de scheme (liens).""" + i_nodes: dict = field(default_factory=dict) # prop -> description factice + scheme_nodes: dict = field(default_factory=dict) # id scheme -> ("RA"|"CA", source, cible) + excluded: list = field(default_factory=list) # (prop, motif d'exclusion) + + +def build_aif_graph(enonces): + """Corpus date -> graphe AIF-conforme, critere d'inclusion C1-C3.""" + g = AIFGraph() + # passe 1 : propositions assertives presentes (C1) a contenu identifiable (C2) + assertees = [e for e in enonces if e.acte == "assertif" and e.prop] + presentes = {e.prop for e in assertees} + # relations exprimees dans la fenetre ; les cibles referencees existent aussi + # comme noeuds-I dans le graphe AIF (une attaque vise un contenu) + relations = [] + for e in assertees: + for scheme, cible in e.relations: + relations.append((scheme, e.prop, cible)) + reliees = {s for _, s, _ in relations} | {c for _, _, c in relations} + # passe 2 : exclusions publiees (C1, C3) + vues = set() + for e in enonces: + if e.prop in vues: + continue # chaque proposition est jugee une seule fois par fenetre + vues.add(e.prop) + if e.acte != "assertif": + g.excluded.append((e.prop, "C1 : acte non assertif")) + elif e.prop in presentes and e.prop not in reliees: + g.excluded.append((e.prop, "C3 : singleton (aucune relation dans la fenetre)")) + # passe 3 : construction (les props exclues C1/C3 ne deviennent PAS noeuds) + exclus = {p for p, _ in g.excluded} + for p in sorted((presentes | reliees) - exclus): + g.i_nodes[p] = f"proposition {p}" + for scheme, src, dst in sorted(set(relations)): + g.scheme_nodes[f"{scheme}_{src}_{dst}"] = (scheme, src, dst) + return g + + +G_t = build_aif_graph(corpus_t) +G_t1 = build_aif_graph(corpus_t1) + +for nom, g in [("G_t", G_t), ("G_t+1", G_t1)]: + n_ca = sum(1 for v in g.scheme_nodes.values() if v[0] == "CA") + n_ra = len(g.scheme_nodes) - n_ca + print(f"{nom} : {len(g.i_nodes)} noeuds-I, {len(g.scheme_nodes)} noeuds de scheme " + f"({n_ca} CA, {n_ra} RA), {len(g.excluded)} exclus") + for prop, motif in g.excluded: + print(f" exclu : {prop} -- {motif}") + + +# ## 3. Conformité AIF — vocabulaire et sérialisation +# +# Le graphe ci-dessus suit le vocabulaire de l'**Argument Interchange Format** (namespace +# `http://www.arg.dundee.ac.uk/aif#`) : nœuds `aif:I-node` (contenus), `aif:RA-node` (support), +# `aif:CA-node` (conflit), liés par des arêtes de scheme. On le vérifie de deux façons, en réutilisant +# la méthode du notebook [Ontology_AIF](Argument_Analysis_Ontology_AIF.ipynb) : l'ontologie Argumentum +# (`argumentum_fallacies.owl`, 4,7 MB) **ne se laisse pas parser par rdflib** (37 axiomes +# `ExactCardinality` mal formés) — on extrait donc ses déclarations de classes par regex tolérant, puis +# on sérialise nos graphes en RDF AIF bien formé. +# + +# In[3]: + + +import re +from pathlib import Path + +from rdflib import Graph, Namespace, URIRef, Literal +from rdflib.namespace import RDF + +AIF = Namespace("http://www.arg.dundee.ac.uk/aif#") + +# 1) Classes AIF reellement declarees dans l'ontologie Argumentum (parseur regex tolere, +# meme methode que Ontology_AIF : rdflib echoue sur les ExactCardinality mal formes) +_THIS_DIR = Path(__file__).resolve().parent +OWL = _THIS_DIR / "ontologies" / "argumentum_fallacies.owl" +owl_text = OWL.read_text(encoding="utf-8", errors="replace") +class_decls = re.findall(r'\s*\s*', owl_text) +aif_classes = {c.split("#")[-1] for c in class_decls if "arg.dundee.ac.uk/aif" in c} +print("Classes AIF declarees dans l'ontologie :", sorted(aif_classes)) + +# 2) Nos types de noeuds doivent etre un sous-ensemble des classes declarees +nos_types = {"I-node", "RA-node", "CA-node"} +assert nos_types <= aif_classes, f"types hors AIF : {nos_types - aif_classes}" +print("Conformite vocabulaire :", sorted(nos_types), "<= classes declarees : OK") + +# 3) Serialisation RDF AIF bien formee d'un graphe +def to_rdf(g): + """AIFGraph -> rdflib.Graph avec triplets AIF (types + arbetes de scheme).""" + rdf = Graph() + rdf.bind("aif", AIF) + for prop in g.i_nodes: + n = URIRef(f"{AIF}{prop}") + rdf.add((n, RDF.type, AIF["I-node"])) + rdf.add((n, URIRef(f"{AIF}text"), Literal(g.i_nodes[prop]))) + for sid, (scheme, src, dst) in g.scheme_nodes.items(): + n = URIRef(f"{AIF}{sid}") + rdf.add((n, RDF.type, AIF[f"{scheme}-node"])) + rdf.add((URIRef(f"{AIF}{src}"), URIRef(f"{AIF}{scheme}edge"), n)) # source -> scheme + rdf.add((n, URIRef(f"{AIF}edge{scheme}"), URIRef(f"{AIF}{dst}"))) # scheme -> cible + return rdf + +rdf_t = to_rdf(G_t) +print(f"\nG_t serialise : {len(rdf_t)} triplets AIF") +print(rdf_t.serialize(format="turtle")[:520]) + + +# ## 4. Du graphe AIF au cadre de Dung +# +# Pour la mesure **sémantique**, on projette le graphe AIF sur un cadre d'argumentation abstrait de Dung : +# +# - les **arguments** = les nœuds-I ; +# - les **attaques** = les liens `CA` : si un `CA-node` relie $x$ (source) à $y$ (cible), alors $x$ attaque $y$ ; +# - les liens `RA` (support) **ne créent pas d'attaque** — la modélisation bipolaire (supports attaquant +# des attaques, cf. AF bipolaires de Cayrol–Lagasquie–Schmid) est **hors scope** : documentée comme limite. +# +# Les définitions de l'acceptation (admissible, *grounded*) sont **reprises telles quelles du substrat** +# [Dung_AF_Semantics](Argument_Analysis_Dung_AF_Semantics.ipynb) (cellules 2 à 10) — sémantique inchangée, +# reproduite ici pour l'auto-contenance du notebook. +# + +# In[4]: + + +# --- Reprise du substrat Argument_Analysis_Dung_AF_Semantics.ipynb (cellules 2-10) --- + +class AF: + """Cadre d'argumentation abstrait de Dung : .""" + + def __init__(self, args, attacks): + self.args = set(args) + self.attacks = set(attacks) # ensemble de couples (attaquant, attaque) + + def attackers(self, x): + return {a for (a, b) in self.attacks if b == x} + + def __repr__(self): + att = ", ".join(f"{a}->{b}" for (a, b) in sorted(self.attacks)) + return f"AF(args={sorted(self.args)}, attacks=[{att}])" + + +def defeats(af, S, x): + """True si S defait x (au moins un membre de S attaque x).""" + return any((a, x) in af.attacks for a in S) + + +def defends(af, S, x): + """True si S defend x : tout attaquant de x est defait par S.""" + return all(defeats(af, S, b) for b in af.attackers(x)) + + +def grounded(af): + """Extension grounded : point fixe a partir de l'ensemble vide.""" + S = set() + while True: + S_new = {x for x in af.args if defends(af, S, x)} + if S_new == S: + return S + S = S_new + + +# --- Projection AIF -> Dung --- + +def to_dung_af(g): + """AIFGraph -> AF : arguments = noeuds-I, attaques = liens CA.""" + attacks = {(src, dst) for (scheme, src, dst) in g.scheme_nodes.values() if scheme == "CA"} + return AF(g.i_nodes.keys(), attacks) + + +af_t, af_t1 = to_dung_af(G_t), to_dung_af(G_t1) +E_t, E_t1 = grounded(af_t), grounded(af_t1) + +print(f"AF_t : {len(af_t.args)} arguments, {len(af_t.attacks)} attaques") +print(f"AF_t+1 : {len(af_t1.args)} arguments, {len(af_t1.attacks)} attaques") +print(f"\nE_t (grounded, {len(E_t)}/{len(af_t.args)}) : {sorted(E_t)}") +print(f"E_t+1 (grounded, {len(E_t1)}/{len(af_t1.args)}) : {sorted(E_t1)}") + + +# ## 5. Deux mesures d'écart de familles différentes +# +# Deux graphes se comparent sur des plans indépendants — on en retient deux, de familles différentes : +# +# **Mesure structurelle** (sur les ensembles, indépendante de toute sémantique) — la **distance de +# Jaccard** $1 - |A \cap A'| / |A \cup A'|$, calculée **deux fois** : sur les ensembles d'attaques +# ($d_{att}$) et sur les ensembles de nœuds ($d_{noeuds}$). Deux graphes identiques ont $d = 0$. +# +# **Mesure sémantique** (sur le verdict d'acceptation, via Dung) : +# +# $$d_{sem}(G, G') \;=\; 1 - \frac{|E \cap E'|}{|E \cup E'|}, \qquad E = Gr(G),\; E' = Gr(G')$$ +# +# Jaccard sur les **extensions grounded**. Deux graphes peuvent être structurellement proches et +# sémantiquement éloignés (une seule attaque bien placée renverse une acceptation), ou l'inverse. +# **Deux mesures qui bougent ensemble se renforcent ; deux qui divergent sont un résultat**, à publier tel quel. +# + +# In[5]: + + +def jaccard_distance(A, B): + """Distance de Jaccard : 1 - |A n B| / |A u B| (0 si les deux sont vides).""" + A, B = set(A), set(B) + if not A and not B: + return 0.0 + return 1.0 - len(A & B) / len(A | B) + + +def d_struct(af1, af2): + """Ecart structurelle : (noeuds, attaques) en distances de Jaccard.""" + d_nodes = jaccard_distance(af1.args, af2.args) + d_att = jaccard_distance(af1.attacks, af2.attacks) + return d_nodes, d_att + + +def d_sem(af1, af2): + """Ecart semantique : Jaccard sur les extensions grounded.""" + return jaccard_distance(grounded(af1), grounded(af2)) + + +# Garde-fou : deux graphes identiques -> ecarts nuls +assert d_struct(af_t, af_t)[0] == 0.0 and d_struct(af_t, af_t)[1] == 0.0 +assert d_sem(af_t, af_t) == 0.0 +print("Sanity : d(G, G) = 0 sur les trois composantes : OK") + + +# ## 6. Contrôle négatif — le plancher de bruit +# +# **Le point central de l'instrument.** Deux graphes construits depuis deux échantillons différents +# diffèrent *toujours*. Une valeur $d > 0$ ne devient un « changement » que si elle dépasse ce que produit +# le **bruit d'échantillonnage seul**. +# +# Le contrôle : découper une **même** période en deux moitiés arbitraires — ici, **première contre +# seconde moitié sur l'ordre d'arrivée** (jours 1–15 vs 16–30 de la période $t$) — construire $G_a$ et +# $G_b$, mesurer $d(G_a, G_b)$. C'est le **plancher de bruit**. Un écart inter-dates qui ne le dépasse +# pas n'est pas un changement : c'est l'instrument qui respire. +# +# Le plancher est publié **à côté** de toute mesure d'écart, dans la même sortie. Ici, la seule +# asymétrie entre moitiés est l'attaque éphémère `p18 → p1` (entendue jours 13, jamais ré-entendue) : +# c'est exactement du bruit d'échantillonnage, et c'est lui qui fixe le plancher. +# + +# In[6]: + + +def split_half(corpus): + """Decoupe une periode en deux moities par ordre d'arrivee (premiere / seconde).""" + trie = sorted(corpus, key=lambda e: e.jour) + milieu = len(trie) // 2 + return trie[:milieu], trie[milieu:] + + +moitie_a, moitie_b = split_half(corpus_t) +G_a, G_b = build_aif_graph(moitie_a), build_aif_graph(moitie_b) +af_a, af_b = to_dung_af(G_a), to_dung_af(G_b) + +floor_nodes, floor_att = d_struct(af_a, af_b) +floor_sem = d_sem(af_a, af_b) + +print(f"Moitie a (jours 1-15) : {len(af_a.args)} arguments, {len(af_a.attacks)} attaques | " + f"grounded {len(grounded(af_a))}") +print(f"Moitie b (jours 16-30) : {len(af_b.args)} arguments, {len(af_b.attacks)} attaques | " + f"grounded {len(grounded(af_b))}") +print(f"\nPlancher de bruit (split moities de la periode t) :") +print(f" d_noeuds = {floor_nodes:.3f}") +print(f" d_att = {floor_att:.3f}") +print(f" d_sem = {floor_sem:.3f}") + + +# ## 7. L'écart inter-dates, avec son plancher à côté +# +# Période $t$ contre période $t+1$. Le changement réel du débat : deux nouvelles attaques (`p15 → p12`, +# `p16 → p11`) et la disparition de l'attaquant éphémère `p18`. Mesuré sur les trois composantes, +# chacune confrontée à son plancher. **Règle de lecture : un écart qui ne dépasse pas son plancher +# n'est pas un changement.** +# + +# In[7]: + + +gap_nodes, gap_att = d_struct(af_t, af_t1) +gap_sem = d_sem(af_t, af_t1) + +print(f"{'composante':<12} {'ecart t->t+1':>14} {'plancher':>10} {'rapport':>9} verdict") +print("-" * 72) +for nom, gap, floor in [("d_noeuds", gap_nodes, floor_nodes), + ("d_att", gap_att, floor_att), + ("d_sem", gap_sem, floor_sem)]: + rapport = gap / floor if floor > 0 else float("inf") + verdict = "DEPASSE" if gap > floor else "sous le bruit" + print(f"{nom:<12} {gap:>14.3f} {floor:>10.3f} {rapport:>8.1f}x {verdict}") + + +# In[8]: + + +# Ce que disent les extensions : qui entre, qui sort +entrees = sorted(E_t1 - E_t) +sorties = sorted(E_t - E_t1) +print(f"Entrees dans l'extension grounded (acceptes en t+1, pas en t) : {entrees}") +print(f"Sorties de l'extension grounded (acceptes en t, plus en t+1) : {sorties}") + + +# In[9]: + + +# Lecture des mouvements (a confronter aux sorties ci-dessus) +print("Lecture des cascades :") +print(" - p15->p12 (non contré) fait sortir p12 ; p12 sortant, p9 n'est plus") +print(" contré -> p9 entre ; et p9 dans l'extension défait p8 -> p7 entre") +print(" (son attaquant p8 est desormais defait).") +print(" - p8 sort : il exigeait la defaite de son attaquant p9, qui n'etait") +print(" defait QUE par p12 -- p12 sortant, p8 perd sa defense.") +print(" - p16->p11 fait sortir p11 (non contré).") +print(" - p18 n'est plus entendu en t+1 : son attaque disparait (avec son noeud).") +print(" - p1 reste dehors : son attaquant p2 n'est jamais defait (p3, seul") +print(" attaquant de p2, n'entre jamais dans l'extension -- p4 indéfaisable).") + + +# ## 8. Contrôle positif — un changement connu par construction +# +# L'instrument doit **détecter** un changement qu'on fabrique exprès. Opération explicite : on ajoute à la +# période $t$ **un seul nouvel énoncé assertif** `p_ctrl` qui attaque `p4`, sans que rien ne contra `p_ctrl`. +# +# **Magnitude attendue — écrite AVANT la mesure** (dérivation à la main, sans exécuter) : +# +# - `p4` était la racine jamais attaquée de la chaîne `p4 → p3 → p2` ; attaqué par `p_ctrl` non +# contré, **`p4` sort** de l'extension (effet direct) ; +# - **effet paradoxal** : `p3`, dont `p4` était le seul attaquant, voit désormais son attaquant +# **défait** (par `p_ctrl`) → **`p3` entre** dans l'extension — attaquer la racine *réhabilite* +# sa victime directe ; +# - **cascade** : `p2` exigeait la défaite de `p3`, assurée par `p4` — `p4` hors extension, `p3` +# n'est plus **défait** par personne (être dans l'extension n'est pas être défait), donc **`p2` +# sort** ; `p1` était déjà dehors ; +# - `p_ctrl` entre (non attaqué) ; rien d'autre ne bouge (le flot `p9/p12`, la branche `p6`, sont +# indépendants de `p4`) ; +# - d'où $E' = E_t \setminus \{p2, p4\} \cup \{p3, p_{ctrl}\}$ : $|E \cap E'| = 6$, +# $|E \cup E'| = 10$ → $d_{sem}$ **attendu $= 1 - 6/10 = 0{,}4$** ; +# - structurellement : $d_{att} = 1 - 9/10 = 0{,}1$ — **sous le plancher de bruit** ($0{,}111$) : +# la mesure structurelle ne doit PAS voir ce changement au-dessus du bruit. C'est précisément +# pourquoi il faut deux familles de mesures. +# + +# In[10]: + + +# L'operation explicite : un seul nouvel enonce, attaque non contree +corpus_ctrl = corpus_t + [Enonce(31, "p_ctrl", "assertif", (("CA", "p4"),))] +G_ctrl = build_aif_graph(corpus_ctrl) +af_ctrl = to_dung_af(G_ctrl) +E_ctrl = grounded(af_ctrl) + +gap_nodes_c, gap_att_c = d_struct(af_t, af_ctrl) +gap_sem_c = d_sem(af_t, af_ctrl) + +print(f"E_ctrl = {sorted(E_ctrl)}") +print(f"\nattendu (ecrit avant) : d_sem = 0.4, d_att = 0.1") +print(f"mesure : d_sem = {gap_sem_c:.3f}, d_att = {gap_att_c:.3f}") +ok_sem = abs(gap_sem_c - 0.4) < 1e-9 +ok_att = abs(gap_att_c - 0.1) < 1e-9 +print(f"\nControle positif : d_sem {'CONFORME' if ok_sem else 'DIVERGE'}, " + f"d_att {'CONFORME' if ok_att else 'DIVERGE'} a l'attendu ecrit") +print(f"p4 sorti : {'p4' not in E_ctrl} | p2 sorti : {'p2' not in E_ctrl} | " + f"p3 entre : {'p3' in E_ctrl} | p_ctrl entre : {'p_ctrl' in E_ctrl}") + + +# ## 9. Lecture — concordance et divergence +# +# Ce que l'instrument a montré sur le corpus synthétique : +# +# - **concordance inter-dates** : les trois composantes dépassent leur plancher (rapports ~2,5× en +# structure, ~5× en sémantique) — le changement de période est réel sur les deux familles à la +# fois, elles se renforcent ; +# - **divergence au contrôle positif** : une seule attaque bien placée produit un $d_{sem}$ de $0{,}4$ +# (détecté, conforme à l'attendu écrit avant — avec sa réhabilitation paradoxale de `p3`) mais un +# $d_{att}$ de $0{,}1$ — **sous le plancher structurel** : invisible en structure. La mesure +# sémantique est la seule à voir ce changement ; +# - la divergence entre familles **est un résultat, pas un échec** : elle dit à quelle famille de +# changement on a affaire (renversement d'acceptation vs volume de structure). +# +# Limite documentée : les liens `RA` (support) ne créent pas d'attaques — la projection AIF → Dung est +# une **perte d'information assumée** (pas de modélisation bipolaire). Une troisième mesure graduée +# (exercice 3) affinerait la granularité du verdict sémantique. +# + +# In[11]: + + +recap = { + "plancher_bruit": {"d_noeuds": round(floor_nodes, 3), "d_att": round(floor_att, 3), + "d_sem": round(floor_sem, 3)}, + "ecart_inter_dates": {"d_noeuds": round(gap_nodes, 3), "d_att": round(gap_att, 3), + "d_sem": round(gap_sem, 3)}, + "controle_positif": {"d_sem_attendu": 0.4, "d_sem_mesure": round(gap_sem_c, 3), + "d_att_attendu": 0.1, "d_att_mesure": round(gap_att_c, 3)}, + "exclusions": {"t": dict(G_t.excluded), "t+1": dict(G_t1.excluded)}, +} +print("Resume de l'instrument (toute mesure d'ecart avec son plancher) :") +for k, v in recap.items(): + print(f" {k} : {v}") + + +# ## 10. L'hypothèse monotone — ce que l'instrument suppose du temps +# +# L'instrument compare $G_t$ et $G_{t+1}$, mais sa **lecture cumulative** des corpus pose une hypothèse +# qui doit être énoncée au lieu de rester implicite (hypothèse absorbée de `Dated_AF_Instrument` §5, +# PR #13340, cellule markdown citée in extenso dans la PR de consolidation) : +# +# > **Hypothèse monotone** : $G_{T_0} \subseteq G_{T_1} \subseteq G_{T_2}$ au sens ensembliste — +# > les arguments ne sont pas retirés ; le retrait est un chantier ultérieur. +# +# Un instrument qui mesure $G_t \to G_{t+1}$ sans dire s'il autorise la disparition d'un argument est +# sous-spécifié. Cette section (1) construit les graphes cumulatifs et **vérifie** la monotonie sur le +# corpus synthétique — falsifiable dans la sortie, pas seulement énoncée en prose — puis (2) la **viole +# délibérément** et montre ce que les deux mesures d'écart en font. +# + +# In[12]: + + +# Graphes cumulatifs : le corpus de T1 = tout ce qui est paru jusqu'a la fin de T1. +cumul_t = corpus_t # jours 1-30 +cumul_t1 = corpus_t + corpus_t1 # jours 1-60 : les enonces s'accumulent, rien ne disparait + +G_cumul_t, G_cumul_t1 = build_aif_graph(cumul_t), build_aif_graph(cumul_t1) +af_cumul_t, af_cumul_t1 = to_dung_af(G_cumul_t), to_dung_af(G_cumul_t1) + +print("Lecture cumulative :") +print(f" G_cumul_t : {len(af_cumul_t.args)} arguments, {len(af_cumul_t.attacks)} attaques") +print(f" G_cumul_t1 : {len(af_cumul_t1.args)} arguments, {len(af_cumul_t1.attacks)} attaques") + +# Verification de l'hypothese monotone (falsifiable en sortie) : +incl_args = set(af_cumul_t.args) <= set(af_cumul_t1.args) +incl_att = set(af_cumul_t.attacks) <= set(af_cumul_t1.attacks) +print("\nHypothese monotone sur ce corpus :") +print(f" args G_cumul_t inclus dans G_cumul_t1 : {incl_args}") +print(f" attacks G_cumul_t inclus dans G_cumul_t1 : {incl_att}") +assert incl_args and incl_att +print(" -> VERIFIEE PAR CONSTRUCTION : sur des corpus emboites, build_aif_graph") +print(" n'exclut jamais un noeud deja relie (C3 ne frappe que les singletons, et") +print(" les relations s'accumulent). Ici la monotonie est un theoreme de") +print(" l'instrument ; elle ne le resterait pas devant un corpus reel retire des enonces.") + + +# ### Violation : un argument retiré entre deux dates +# +# L'instrument n'a pas d'acte « rétractif » : le corpus non-monotone se construit en retirant du +# cumul les énoncés d'un argument — c'est exactement le graphe qu'un futur acte rétractif produirait. +# Deux familles de retraits ne parlent pas aux deux mesures de la même façon : +# +# - retirer un argument **hors de l'extension grounded** (défait) : la mesure structurelle le voit, +# la mesure sémantique peut rester immobile — sauf si l'argument retiré **attaquait** un candidat +# à l'acceptation, auquel cas sa disparition peut *faire entrer* ce candidat dans grounded ; +# - retirer un argument **dans l'extension grounded** (accepté) : la mesure sémantique bouge +# nécessairement (l'accepté disparaît de l'extension). +# + +# In[13]: + + +def sans_argument(corpus, prop): + """Corpus non-monotone : tous les enonces portant `prop` ou le ciblant sont retires. + + C'est le graphe qu'un acte retractif produirait : l'argument disparait avec ses + relations, y compris les attaques QUI LE VISENT (une attaque sans cible n'a pas + de sens dans le cadre de Dung).""" + return [e for e in corpus + if e.prop != prop and all(cible != prop for _, cible in e.relations)] + +E_cumul_t = grounded(af_cumul_t) +dans_grounded = sorted(E_cumul_t) +hors_grounded = sorted(set(af_cumul_t1.args) - E_cumul_t) # args de t+1 hors grounded de t + +print(f"Grounded de G_cumul_t ({len(dans_grounded)}/{len(af_cumul_t.args)}) : {dans_grounded}") +print(f"Hors grounded ({len(hors_grounded)}) : {hors_grounded}\n") + +# (a) Tous les retraits hors-grounded : distribution honnete de l'effet sur d_sem +print("Retrait d'un argument DEFAIT (hors grounded) — effet mesure un par un :") +n_sem_nul = n_sem_bouge = 0 +for prop in hors_grounded: + af_r = to_dung_af(build_aif_graph(sans_argument(cumul_t1, prop))) + d_n, d_a = d_struct(af_cumul_t1, af_r) + d_s = d_sem(af_cumul_t1, af_r) + tag = "d_sem immobile" if d_s == 0.0 else f"d_sem BOUGE ({d_s:.3f})" + print(f" retrait '{prop}': d_noeuds={d_n:.3f} d_att={d_a:.3f} d_sem={d_s:.3f} {tag}") + if d_s == 0.0: + n_sem_nul += 1 + else: + n_sem_bouge += 1 +print(f" -> {n_sem_nul} retraits laissent grounded inchange ; {n_sem_bouge} le font bouger") +print(" (un defait qui ATTAQUAIT un candidat peut, en disparaisant, le faire accepter)\n") + +# (b) Retrait d'un argument ACCEPTE : la mesure semantique bouge necessairement +prop_accepte = dans_grounded[0] +af_r_acc = to_dung_af(build_aif_graph(sans_argument(cumul_t1, prop_accepte))) +d_n_acc, d_a_acc = d_struct(af_cumul_t1, af_r_acc) +d_s_acc = d_sem(af_cumul_t1, af_r_acc) +print(f"Retrait de '{prop_accepte}' [ACCEPTE, dans grounded] :") +print(f" d_noeuds = {d_n_acc:.3f} d_att = {d_a_acc:.3f} d_sem = {d_s_acc:.3f}") + +# Assertions robustes : ce que l'instrument FAIT quand l'hypothese est violee +assert d_n_acc > 0.0 and d_s_acc > 0.0, "le retrait d'un accepte doit bouger les deux mesures" +assert all(d_struct(af_cumul_t1, to_dung_af(build_aif_graph(sans_argument(cumul_t1, p))))[0] > 0.0 + for p in hors_grounded), "tout retrait doit etre visible structurellement" +print("\nCe que l'instrument fait sous violation :") +print(" d_noeuds > 0 pour TOUT retrait : la mesure structurelle voit chaque disparition ;") +print(" d_sem ne bouge QUE si le retrait touche l'extension grounded (directement, ou via") +print(" un attaquant disparu qui libere un candidat) ;") +print(" AUCUNE des deux mesures ne dit RETRAIT vs APPARITION : le sens du mouvement n'est") +print(" pas dans l'ecart de graphes — il est dans les evenements dates du corpus. C'est") +print(" la limite explicite que l'hypothese monotone permettait de ne pas poser.") + + +# ## 11. Exercices +# +# Trois exercices pour prolonger l'instrument — chacun suit un exemple guidé de ce notebook. +# + +# ### Exercice 1 — Distance d'édition normalisée +# +# **Contexte.** La distance de Jaccard traite ajout et suppression symétriquement et ignore la taille +# des graphes. Une **distance d'édition** (nombre d'ajouts + suppressions d'arêtes pour transformer $A$ +# en $A'$, normalisé par la taille du plus grand graphe) pondère différemment les petites évolutions. +# +# **Objectif.** Implémenter `edit_distance_attaques(af1, af2)` $= (|A_1 \setminus A_2| + |A_2 \setminus A_1|) / \max(|A_1|, |A_2|)$, +# puis la comparer à $d_{att}$ sur l'écart inter-dates et sur le contrôle positif : divergent-elles +# sur le classement des deux situations ? +# +# **Indices.** `af.attacks` est un ensemble de couples — les différences ensemblistes suffisent ; +# attention au cas où les deux ensembles sont vides (retourner 0, comme `jaccard_distance`). +# + +# In[14]: + + +def edit_distance_attaques(af1, af2): + """TODO etudiant : distance d'edition normalisee sur les ensembles d'attaques. + + (|A1 \ A2| + |A2 \ A1|) / max(|A1|, |A2|), et 0.0 si les deux sont vides. + """ + # Etape 1 : extraire les deux ensembles d'attaques. + # Etape 2 : calculer les ajouts et suppressions (differences ensemblistes). + # Etape 3 : normaliser par la taille du plus grand ensemble. + print("Exercice a completer") + return None + + +print("Exercice 1 a completer : fonction definie, a vous de l'appeler sur af_t / af_t1") +resultat_ex1 = None # TODO etudiant : edit_distance_attaques(af_t, af_t1) + + +# ### Exercice 2 — Plancher de bruit par split aléatoire répété +# +# **Contexte.** Le split première/seconde moitié donne **un** plancher. La distribution du plancher sous +# découpage aléatoire (20 tirages de moitiés aléatoires) dit si le plancher observé est stable ou +# chanceux — c'est la version robuste du contrôle négatif. +# +# **Objectif.** Répéter 20 fois : permutation aléatoire du corpus $t$, split en deux moitiés, mesure +# $d_{sem}(G_a, G_b)$. Publier moyenne, écart-type, min, max. L'écart inter-dates dépasse-t-il la borne +# haute du plancher (et non plus seulement une seule réalisation) ? +# +# **Indices.** `random.Random(42)` pour un tirage reproductible ; `random.sample(corpus, len(corpus))` +# pour permuter ; les fonctions `build_aif_graph`, `to_dung_af`, `d_sem` existent déjà. +# + +# In[15]: + + +import random + + +def plancher_aleatoire(corpus, n_tirages=20, seed=42): + """TODO etudiant : distribution du plancher de bruit sous splits aleatoires. + + Renvoie la liste des n_tirages valeurs de d_sem(G_a, G_b). + """ + # Etape 1 : rng = random.Random(seed). + # Etape 2 : pour chaque tirage, permuter le corpus puis le couper en deux moities. + # Etape 3 : construire les graphes, mesurer d_sem, accumuler. + print("Exercice a completer") + return None + + +print("Exercice 2 a completer : fonction definie, a vous de produire la distribution") +distribution_ex2 = None # TODO etudiant : plancher_aleatoire(corpus_t) + + +# ### Exercice 3 — Troisième mesure : classement gradué (fardeau) +# +# **Contexte.** L'acceptation binaire de Dung écrase la variation : un argument marginalement attaqué +# et un argument submergé sont « dehors » pareillement. Les **sémantiques de classement** du substrat +# [Ranking_Semantics](Argument_Analysis_Ranking_Semantics.ipynb) (h-Categoriser, fardeau) donnent à chaque +# argument une *force* numérique — une troisième mesure d'écart, graduée, peut voir des déplacements +# invisibles à $d_{sem}$. +# +# **Objectif.** Implémenter le classement par **fardeau** (burden) puis mesurer $d_{rank}$ = distance de +# Spearman (ou Kendall tau) entre les classements de $G_t$ et $G_{t+1}$. +# +# **Indices.** Le fardeau de $x$ = nombre d'attaquants de $x$ non défaits par l'extension grounded $E$ ; +# `scipy.stats.spearmanr` si scipy est disponible, sinon des rangs moyens à la main sur les ex-aequo. +# + +# In[16]: + + +def fardeau(af, E): + """TODO etudiant : fardeau de chaque argument = attaquants non defaits par E.""" + # Indice : un attaquant a est "defait" si un membre de E l'attaque. + print("Exercice a completer") + return None + + +def d_rank(af1, af2): + """TODO etudiant : ecart de classement (Spearman) entre les fardeaux de deux graphes.""" + # Etape 1 : calculer les fardeaux avec l'extension grounded de chaque graphe. + # Etape 2 : ranger les arguments communs par fardeau croissant dans chaque graphe. + # Etape 3 : correlation de Spearman entre les deux rangs (1 - rho = distance). + print("Exercice a completer") + return None + + +print("Exercice 3 a completer : fonctions definies, a vous de produire le classement") +resultat_ex3 = None # TODO etudiant : d_rank(af_t, af_t1) + + +# ## Conclusion — ce que l'instrument sait, et ne sait pas +# +# **Sait** : transformer un corpus daté en graphe AIF conforme (critère d'inclusion écrit C1–C3, +# exclusions publiées), le projeter sur Dung, mesurer un écart sur deux familles indépendantes +# (structurelle, sémantique), et **situer chaque écart par rapport à son plancher de bruit** — avec un +# contrôle positif dont la magnitude attendue était écrite avant la mesure, et qui distingue ce que +# chaque famille voit (un renversement d'acceptation est invisible en structure). +# +# **Ne sait pas (encore)** : rien sur un phénomène réel — l'instrument est neutre quant au domaine ; le +# brancher sur l'humour, le désir ou l'attachement est un grain ultérieur de l'Epic +# [#13303](https://github.com/jsboige/CoursIA/issues/13303), et le choix d'un corpus daté réel attend le +# protocole [#13309](https://github.com/jsboige/CoursIA/issues/13309). La projection AIF → Dung ignore +# les supports (pas de bipolaire) ; le classement gradué (exercice 3) est la voie naturelle pour +# affiner le verdict sémantique. +#