Sunday Query : utiliser SPARQL et Python pour corriger des coquilles sur Wikidata

À mon tour de faire une #SundayQuery! Comme Harmonia Amanda l’a dit dans son propre billet, j’envisageais de faire un article expliquant comment créer un script Python permettant de corriger les résultats de sa requête.. Finalement, j’ai préféré en faire un autre, au fonctionnement similaire mais plus court et plus simple à comprendre. Le script pour Harmonia est cependant disponible en ligne ici.

Jeudi, j’ai publié un article au sujet des  batailles du Moyen Âge, et depuis, j’ai commencé à corriger les éléments correspondants sur Wikidata

L’une des corrections les plus répétitives était la capitalisation des libellés en français : comme ils ont été importés de Wikipédia, ils ont une majuscule inutile au début ( « Bataille de Saint-Pouilleux en Binouze » instead of « bataille de Saint-Pouilleux en Binouze »…)

La requête

Commençons par trouver tous les éléments présentant cette coquille.

http://tinyurl.com/jljf6xr

Quelques explications de base :

  • ?item wdt:P31/wdt:P279* wd:Q178561 .  cherche les éléments qui sont des batailles ou des sous-classes de batailles, pour être bien sûr que je ne vais pas virer sa majucule à un bouquin intitulé  « Bataille de Perpète-les-Olivettes »…
  • Sue la ligne suivante, je demande les libellés pour les éléments en question  ?item rdfs:label ?label .  et les filtre pour ne garder que ceux en français FILTER(LANG(?label) = "fr") . . Comme j’ai besoin d’utiliser le libellé dans la requête et pas juste de l’afficher (et comme Harmonia Amanda l’a expliqué dans son billet de dimanche), je ne peux pas utiliser le servicce wikibase:label, et je me rabats donc sur le standard du web sémantique rdfs:label.
  • La dernière ligne est un FILTER  (filtre), qui ne garde que les résultats qui répondent à la fonction à l’intérieur. Ici, STRSTARTS  vérifie si ?label  commence avec "Bataille " .

Au moment où j’ai écrit la version anglaise de ce texte, la requête renvoyait 3521 résultats. Beaucoup trop pour les corriger à la main, et je ne connais aucun outil déjà existant qui pourrait faire ça pour moi… Je suppose qu’il est temps de dégainer Python, du coup !

Le script Python

J’aime Python. J’adore carrément Python, même. Ce langage est génial pour créer une une application utile en une poignée de minutes, facile à lire (pour peu qu’on lise l’anglais) et pas constellé de séries d’accolades ou de points-virgules, et a des libs géniales pour les choses que je fais le plus avec : récupérer le contenus de pages web, trier des données, vérifier des ISBNs [1]J’espère pouvoir bientôt quelque chose ici sur ce sujet. et faire des sites web. Oh, et pour faire des requêtes SPARQL et traiter les résultats [2]En plus, les exemples dans la doc officielle sont basés sur Firefly. Yes sir, Captain Tightpants..

Deux charmeurs de serpent avec un python et deux cobras.
Et puis le nom du langage a un petit côté « charmeur de serpents » 😉

Premières remarques

Si vous ne connaissez pas du tout le Python, cet article n’est pas le bon endroit pour ça, mais il y a de nombreuses ressources disponibles en ligne [3]Par exemple, https://www.codecademy.com/learn/python ou https://docs.python.org/3.5/tutorial/.. Assurez-vous juste qu’elles sont à jour et pensées pour Python 3. La suite de cet article part du principe que vous avez une connaissance basique de Python (indentation, variables, chaînes de caractères, listes, dictionnaires, imports et boucles for.), et que Python 3 et pip sont installés sur votre machine.

Pourquoi Python 3 ? Parce que nous allons manipuler des chaînes qui viennent de Wikidata et sont donc encodées en UTF-8 et que Python 2 n’est pas hyper pratique pour ça. Et puis mince, on est en 2016, par Belenos !

Pourquoi pip ? Parce qu’on a besoin d’une libraire non-standard pour faire des requêtes SPARQL, appelée SPARQLwrapper, et que cette commande est le moyen le plus simple de l’installer :

Allez, on commence à scripter !

Pour commencer, un script qui fait une requête Sparql retournant la liste des sièges à corriger [4]Oui, les sièges, j’ai déjà corrigé les batailles avant d’écrire le billet 😉 :

Ça fait un bon petit paquet de lignes, mais que font-elles ? Comme on va le voir, la plupart vont en fait être incluses à l’identique dans tout script qui fait une requête  SPARQL.

  • Pour commencer, on importe deux choses du module  SPARQLWrapper : la classe SPARQLWrapper elle-même et la constante « JSON » qu’elle va utiliser plus tard (pas d’inquiétude, on n’aura pas à manipuler du json directement.)
  • On import aussi le module « Pretty printer » pour afficher les résultats de manière plus lisible.
  • Ensuite, on crée une variable qu’on nomme « endpoint », qui contient l’URL complète vers le point d’accès SPARQL de Wikidata [5]Et non son accès web qui est simplement « https://query.wikidata.org/ ».
  • Ensuite, on crée une instance de la classe SPARQLWrapper qui utilisera ce point d’accès pour faire des requêtes, et on les met dans une variable simplement appelée  « sparql ».
  • On applique à cette variable la fonction setQuery, qui est l’endroit où l’on rentre la requête de tout à l’heure. Attention, il faut doublonner les accolades (remplacer { et } par {{ et }}, car elles sont des caractères réservés dans les chaînes Python.
  • sparql.setReturnFormat(JSON)  dit au script que le résultat sera retourné en json.
  • results = sparql.query().convert() , enfin, fait la requête elle-même et convertit la réponse dans un dictionnaire Python appelé  « results ».
  • Et pour l’instant, on va juste afficher le résultat à l’écran pour voir ce qu’on obtient.

Ouvrons un terminal et lançons le script :

C’est un gros paquet de résultats mais on peut voir que c’est un dictionnaire qui contient deux entrées :

  • « head », qui contient les noms des deux variables renvoyées par la requête,
  • et « results », qui contient lui-même un autre dictionnaire avec la clef « bindings », associée avec la liste des résultats eux-mêmes, chacun d’entre eux étant lui-même un dictionnaire  Python. Pfiou…

Examinons un desdits résultats :

C’est un dictionnaire avec deux clefs (label et item), chacune ayant pour valeur un autre dictionnaire qui à son tour a une clef « value » associée avec, cette fois, la valeur qu’on veut au final. Enfin !

Parcourir les résultats

Parcourons la liste « bindings » avec une boucle « for » de Python, pour pouvoir en extraire les résultats.

Rapide explication sur la ligne  qid = result['item']['value'].split('/')[-1]  : comme l’identifiant de l’élément est en fait stocké sous la forme d’une URL complète (« https://www.wikidata.org/entity/Q17627724 » et pas juste « Q17627724 »), il nous faut séparer cette chaîne à chaque caractère ‘/’, ce qu’on fait à l’aide de la fonction « split()« , qui transforme la chaîne en une liste Python contenant ceci :

Nous ne voulons que le dernier élément de cette liste. En Python, c’est celui avec l’index -1, d’où le [-1] à la fin de la ligne. Enfin, nous stockons cette valeur dans la variable qid.

Lançons le script ainsi modifié :

Corriger le problème

On y est presque ! Maintenant, il reste à remplacer cet orgueilleux « S » majuscule par un plus modeste « s » minuscule :

Que se passe-t-il ici ? Une chaîne Python fonctionne comme une liste, on peut donc lui demander de prendre la partie située entre le début de la chaîne « label » et la position qui suit le premier caractère (« label[:1] ») et forcer celui-ci en bas-de-casse (« .lower() »). Ensuite, on y concatène le reste de la chaîne (de la position 1 à la fin, donc « label[1:] ») et on réassigne ce résultat à la variable « label ».

Dernière chose, formater le résultat de manière compatible à QuickStatements:

Cette ligne semble barbare ? Elle est en fait assez simple : "{}\tLfr\t{}"  est une chaîne qui contient un premier emplacement pour le résultat d’une variable (« {} »), puis une tabulation, (« \t »), puis le mot-clef Quickstatements pour le libellé français (« Lfr »), une autre tabulation et enfin le second emplacement pour une variable. Ensuite, la fonction « format() » se charge de mettre le contenu des variables « qid » et « label » dedans. Le script final devrait ressembler à ça :

C’est parti :

On est bons ! Il ne reste plus qu’à copier-coller le résultat dans QuickStatements et attendre qu’il fasse le boulot tout seul.

Image à la une : Photographie de fontes de caractères par Andreas Praefcke (domaine public)

Enregistrer

Enregistrer

Notes   [ + ]

1. J’espère pouvoir bientôt quelque chose ici sur ce sujet.
2. En plus, les exemples dans la doc officielle sont basés sur Firefly. Yes sir, Captain Tightpants.
3. Par exemple, https://www.codecademy.com/learn/python ou https://docs.python.org/3.5/tutorial/.
4. Oui, les sièges, j’ai déjà corrigé les batailles avant d’écrire le billet 😉
5. Et non son accès web qui est simplement « https://query.wikidata.org/ »

De la classification sur Wikidata

Quand on classe des trucs, on a grosso-modo deux approches possibles : des cases hyper-pointues allant aussi loin que possible dans le détail, ou au contraire des cases très larges en laissant à l’utilisa⋅teur⋅trice le soin de croiser pour trouver ce qui l’intéresse. Wikimedia Commons a grosso-modo choisi la première approche, avec des catégories comme « [[Category:Castles in Andalusia at night]] [1]Je suis déçu : il n’y a pas de sous-catégorie [[Category:Castles in Andalusia at night facing left]]. », alors que Wikidata a plutôt choisi l’approche inverse, avec un grand nombre de propriétés que l’on peut croiser par le biais de requêtes SPARQL (entre autres), ce qui donne pour l’Alhambra de Grenade : « Nature de l’élément (P31) → château fort (Q23413) » et « Pays (P17) → Espagne (Q29) » et tout un tas d’autres déclarations [2]Wikipédia, quant à elle, hésite entre ces deux approches, selon les catégories..

Un château en Espagne (allégorie)
Un château en Espagne (satire)

Je dis « plutôt choisi » parce qu’il arrive qu’on tombe sur des cas où une classe tente de mélanger plusieurs axes, et qui suffisent généralement à démontrer pourquoi cette approche est une mauvaise idée. En l’espèce, je vais me pencher sur l’élément « bataille du Moyen Âge (Q15991159) » : ça tombe bien, c’est un domaine qui me botte.

Le problème des classes aux limites arbitraires

Le premier problème qui vient à l’esprit, c’est les limites de la classe : si on se fie à sa description en français, la seule présente lors de sa création, cette classe est censée contenir des « bataille[s] ayant eu lieu pendant la période du moyen âge, en occident ». Bon, la description précise explicitement l’Occident qui n’était qu’implicitement défini par le fait que le Moyen Âge est une notion d’historiographie occidentale, mais les limites restent assez floues.

  • Bataille en soi est discutable (on peut se poser la question d’y inclure ou non les sièges, par exemple), mais partons du principe que l’on a la même définition que pour l’élément « bataille (Q178561) » tout court qu’il remplace en tant que nature de l’élément (P31).
  • Par contre, « la période du moyen âge », c’est largement plus subjectif. Les dates varient pas mal. Si la plupart des historiens s’accordent à situer le début à l’année 476 [3]En revanche, la culture populaire a tendance à ignorer complètement le haut Moyen Âge. Tous les jeux de stratégie médiévale que je connais commencent en 1066 avec l’expédition de Guillaume le Conquérant. Si la période précédente est abordée, c’est dans des extensions, comme Crusader Kings II avec Charlemagne., la fin est plus floue. Même en ignorant le « long Moyen Âge » de Jacques Le Goff qui s’étend en gros de Justinien à la Révolution française, les dates généralement admises varient par pays mais sont situées dans la seconde moitié du XVe siècle.
  • Enfin, comme dit plus haut, le Moyen Âge est un concept occidental, ce qui recouvre une zone aux contours flous, aussi bien à l’époque actuelle que si on se limite au Moyen Âge : au début de la période, c’est l’Empire romain d’Occident, celui qui vient de s’effondrer sous les coups des barbares assoiffés de sang qui, etc., vous connaissez le roman national. À la fin, c’est, pour faire simple, la Chrétienté [4]enfin pas l’Éthiopie non plus, faut pas déconner., ce qui pose d’autres questions : à partir de quand peut-on inclure les batailles dans les pays orthodoxes ? Et dans les pays scandinaves/germaniques/nordiques ? Et les batailles impliquant des forces européennes mais se passant ailleurs, comme les Croisades, on les compte ? Autrement dit pour ce dernier cas, on définit « Occident » par le lieu de l’action ou par l’origine des belligérants ?

Par comparaison, l’approche standard de Wikidata (en utilisant « nature de l’élément (P31)bataille (Q178561) », date (P585) [5]ou « date de début (P580) » et « date de fin (P582) » si besoin., « lieu (P276) » et « participant (P710) » pour les cas abordés dans la liste ci-dessus) se révèle à la fois plus souple (on peut être très précis⋅e sur la date comme sur le lieu) et moins subjective que l’apposition de la classe bataille du moyen Âge…

Les données réelles

Du coup, avec des définitions aussi floues, on peut se demander comment les données sont utilisées…

Cherchons donc ça.

On a donc 687 éléments ayant cette nature, dont voici la liste par ordre alphabétique.

Répartition géographique

Plaçons donc les résultats sur une carte :

bataillesmoyenagecarte

http://tinyurl.com/jf4kvzw

Ouch, ça pique. Je veux bien admettre qu’on compte les batailles au Proche Orient, probablement liées aux Croisades, mais des chevaliers médiévaux dans le Golfe de Guinée ? au Mexique ? au Japon ? Il semblerait que la limitation de la classe à l’Occident ne soit pas une évidence pour tout le monde. Argh. Mais on est au moins raccord sur la période alors ?

Répartition temporelle

bataillesmoyenagefrisehttp://tinyurl.com/hkb6cqf

Si on place les résultats sur une frise, c’est nettement meilleur que sur la carte, avec seulement deux résultats manifestement erronés : la bataille d’Andrinople en 378 et celle de Szikszó en 1588 (vous ne devriez pas les voir ressurgir dans les résultats, j’ai corrigé ça.) Reste que la limite de fin reste arbitraire, contrairement à une requête normale où l’on peut choisir celle qui nous intéresse, que ça soit 1453 [6]Quand les Bretons sont venus sauver le cul des Français une énième fois à la bataille de Castillon. Ou alors c’est pour l’invention de l’imprimerie mobile par Gutenberg, ou la chute de Constantinople, allez savoir., 1492 [7]Quand les Français, ingrats (cf. note précédente), s’emparent de « la ville de Rennes et la fille qui est dedans », à moins que ça n’ait trait à la fin de la Reconquista et à l’expédition de Christophe Colomb…, ou n’importe quelle autre.

Si on résume, l’arbitraire des choix impliqués par une classe croisant de multiples critères la rend inutilisable en pratique, tout en compliquant la recherche des batailles « normales » puisque qu’on doit du coup inclure les sous-classes, juste pour récupérer les batailles ayant eu lieu en Europe (ou pas) entre 476 et 1453, ou 1492, ou 1500 (enfin on l’espère).

Ma conclusion ? Comme disait Jeanne la Flamme au siège d’Hennebont, foutez-y-moi le feu.

jeanne_flamme
Jeanne la Flamme, par Jeanne Malivel (1926)

Image d’en-tête : Le Combat des Trente (détail), par Pierre Le Baud (1480-1482)

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Enregistrer

Notes   [ + ]

1. Je suis déçu : il n’y a pas de sous-catégorie [[Category:Castles in Andalusia at night facing left]].
2. Wikipédia, quant à elle, hésite entre ces deux approches, selon les catégories.
3. En revanche, la culture populaire a tendance à ignorer complètement le haut Moyen Âge. Tous les jeux de stratégie médiévale que je connais commencent en 1066 avec l’expédition de Guillaume le Conquérant. Si la période précédente est abordée, c’est dans des extensions, comme Crusader Kings II avec Charlemagne.
4. enfin pas l’Éthiopie non plus, faut pas déconner.
5. ou « date de début (P580) » et « date de fin (P582) » si besoin.
6. Quand les Bretons sont venus sauver le cul des Français une énième fois à la bataille de Castillon. Ou alors c’est pour l’invention de l’imprimerie mobile par Gutenberg, ou la chute de Constantinople, allez savoir.
7. Quand les Français, ingrats (cf. note précédente), s’emparent de « la ville de Rennes et la fille qui est dedans », à moins que ça n’ait trait à la fin de la Reconquista et à l’expédition de Christophe Colomb…

Prototypage rapide d’entités Wikidata

W

ikidata est un projet puissant, mais pour pouvoir en profiter, il faut le remplir. Bien sûr, il est possible d’ajouter des données à la main directement sur le site : ça fonctionne sans souci mais que faire quand on a plusieurs dizaines d’entrées ? Plusieurs centaines ? Avec le temps, quelques outils ont vu le jour permettant d’automatiser un peu ce processus, comme l’indispensable QuickStatements ; là encore, il faut bien lui fournir les données qu’il mange et tout ceci est parfois bien fastidieux. Est-ce qu’on ne peut pas aller plus loin ?

Une entrée wikidatienne sur un être humain, par exemple, possède des champs plus ou moins codifiés :

  • Une nature de l’élément égal à Q5, « être humain »
  • Un genre (masculin, féminin, etc.)
  • Une date et un lieu de naissance
  • Une date et un lieu de décès
  • Un pays de nationalité
  • Une occupation
  • Un prénom et un nom de famille

Toutes ces propriétés ne se rencontrent pas à chaque fois et on peut en rajouter d’autres, mais est-ce que ça ne serait pas intéressant d’avoir une sorte de formulaire où on se contenterait de remplir les cases correspondantes, histoire de créer rapidement un squelette d’entité Wikidata ? Pour voir, j’ai codé l’utilitaire « human » : vous mettez ce qu’il faut dans les champs, vous appuyez sur le bouton et il produit le code à destination de QuickStatements. J’ai le sentiment qu’on peut ainsi gagner du temps et on n’oublie rien.

Histoire d’aller un peu plus loin, je me suis penché sur la base Joconde, qui a le goût d’être remplie avec pas mal de soin. J’en ai sorti un autre utilitaire, baptisé Joconde parce que je suis un gars avec une imagination débordante. Sa fonction : parser une fiche Joconde, en retirer les champs qui vont bien (titre, auteur, sujets représentés, etc.) et présenter le résultat dans un formulaire pour correction avant envoi à QuickStatements. Les champs corrigés sont stockés en base, histoire que si l’utilitaire puisse ressortir la correction s’il retombe dessus (comme ça, « MONET Claude » est automatiquement corrigé en « Claude Monet, peintre français, Q296 »). On y gagne du temps.

Personnellement, je pense que cette idée de formulaires pré-remplis est à creuser, pour Wikidata. Qu’en pensez-vous ?

PS : j’imagine que mon outil pour Joconde doit enfreindre des milliers de lois, de licences et de copyrights. Je laisse le soin aux Wikimédiens que ça intéresse de discuter de la chose et de décider s’il faut supprimer toutes mes contributions. Prévenez-moi juste du résultat final.

(Image d’en-tête : détail de La Joconde, par Léonard de Vinci (domaine public))

Mise à jour du 28 septembre 2016 : j’ai placé le code des outils en question sur GitHub : https://github.com/PoulpyFR. Bien sûr, c’est codé n’importe comment et pas commenté. 🙂