Contact

Situation professionnelle

Entrepreneur
En simple veille

Fabien Poulard

Blog d'un jeune (chercheur) entrepreneur en TAL

Visualiser la distribution d'un attribut Mongo avec R
07 août 2013

Les statistiques descriptives ont ceci de formidable qu'elles permettent rapidement de se donner une idée de ce à quoi ressemblent des données... ces fameuses données qui peuplent nos bases de données à Dictanova :)

Ainsi, nous souhaitons parfois savoir quelles sont les valeurs d'un attribut particulier.

Il suffit d'exporter toutes les valeurs de l'attribut (par exemple relevance), soit pour MongoDB :

mongoexport -d verbatims -c verbatim \
   --query='{"missionId": ObjectId("5200e62ae4b08a85995c9b08")}' \
   -f relevance \
   --csv --slaveOk=true \
   --out=relevance-mission-5200e62ae4b08a85995c9b08.csv

... puis calculer dans R la distribution de cet attribut grâce à la fonction density :

d <- read.table("relevance-mission-5200e62ae4b08a85995c9b08.csv", header=T, sep=",")
plot(density(d))

Distribution des valeurs d'un attribut

... et potentiellement pour pousser un peu la réflexion, comparer la distribution en histogrammes des valeurs de l'attribut avec la distribution normale :

# Histograms with Normal Curve
# http://www.statmethods.net/graphs/density.html
r <- d$relevance
h <- hist(r, breaks=20)
xfit <- seq(min(r), max(r), length=40)
yfit <- dnorm(xfit, mean=mean(r), sd=sd(r))
yfit <- yfit*diff(h$mids[1:2])*length(r)
lines(xfit, yfit, col="blue", lwd=2)

Comparaison de la distribution des valeurs par rapport à la distribution normale

Voir l'article
Piloter Gnumeric depuis Python
08 mai 2013

Gnumeric est le tableur de l'environnement GNOME. Il a l'avantage d'être beaucoup plus léger que LibreOffice Calc ce qui le rend particulièrement agréable d'utilisation pour travailler sur des tableaux de taille raisonnable.

Habituellement, je travaille dans un premier temps mes données sous Gnumeric, Calc ou Open Refine selon leur complexité et bien sûr lorsqu'elles peuvent tenir en mémoire. Une fois les données nettoyées, j'exporte le tout en CSV pour les analyser avec R ou directement en Python. Cependant je me suis récemment aperçu que Gnumeric offrait une console Python... j'ai donc essayé.

Accéder à la console Python

Pour accéder à la console Python de Gnumeric, il faut tout d'abord installer les greffons Python plugin et Python plugin loader. Sous Debian, ceux-ci sont disponibles par l'installation du paquet gnumeric-plugins-extra.

Une fois lesdits greffons installés, il faut aller les activer en passant par Outils > Greffons puis en cochant Chargeur de greffon Python. Après redémarrage de Gnumeric, la console Python devrait être directement accessible par une entrée du menu Outils.

Le site de Gnumeric propose un tutoriel pour activer la console mais il semble un peu dépassé ou bien pas adapté au packaging Debian.

Jouer avec la console

Pour mon cas test, j'avais un besoin très simple. J'avais construit un fichier Gnumeric avec plusieurs feuilles dont l'une d'elle contenait une liste d'adresses Web. Je souhaitais obtenir la liste ordonnée des top domaines de ces adresses.

Première étape, récupérer la feuille où se trouvent mes données :

# Chargement du module pour accéder à l'API gnumeric
import Gnumeric
 
# Récupération du classeur
# (j'ai une seule instance d'ouverte, il s'agit donc du premier)
wb = Gnumeric.workbooks()[0]
 
# Recherche de ma feuille
[s.get_name_unquoted() for s in wb.sheets()]
# ... que j'identifie comme la première
sheet = wb.sheets()[0]

Premier constat, un interpréteur Python auquel on ne peut soumettre qu'une ligne à la fois ce n'est vraiment pas très pratique.

Je souhaite maintenant accéder aux données de la colonne C :

# Accès au contenu de C2
sheet[2,1].get_value()
 
# Récupération de l'ensemble des données dans une liste
# (one-liner à cause de l'interpréteur)
l = [sheet[2,i].get_value() for i in range(5000) if not sheet[2,i].get_value() is None]
# len(l) -> 4977 ce qui correspond à ce que j'attends

Et finalement je peux réaliser mon opération :

# utilisation d'urlparse pour parser les urls
from urlparse import urlparse
 
# récupération des top domaines
# (désolé, encore un one-liner)
domains = [".".join(urlparse(u).netloc.split(".")[-2:]) for u in l]
 
# comptage
occ = dict([(d, domains.count(d)) for d in set(domains)])
 
# classement
from operator import itemgetter
sdomains = sorted(occ.items(), key=itemgetter(1), reverse=True)

Et voilà...

Piloter le contenu d'un tableur à partir d'une console python peut parfois être très intéressant. Pour autant, limiter le champs de l'interpréteur à une seule ligne ne rend pas la chose très aisée.

Voir l'article
Web2Day 2013 : données et analyse prédictive
05 mai 2013

Les 16 et 17 mai prochains se déroulera la 5ème édition du web2day, le festival du numérique dédié à lʼinnovation et au Web. L'évènement est organisé par lʼassociation Atlantic 2.0, le réseau des acteurs du Web et de lʼinnovation numérique en Pays de la Loire, et prendra place à Stereolux, sur lʼîle de Nantes, pour la deuxième année consécutive.

Lors de cette édition j'aurai l'immense plaisir d'animer la partie du thème sur l'enjeux des données dédiée à l'analyse prédictive, ou «Comment peut-on utiliser les données du moment pour prédire les données à venir ?». L'autre partie du thème sur l'opportunité économique des données est gérée par Claire de Libertic, ultime experte de l'open data.

Personnellement, je suis convaincu que les données sont le pétrole de demain. Nous travaillons d'ailleurs, à Dictanova, sur des systèmes d'extractions et des raffineries dédiées à ces nouveaux composés numériques. Leur exploitation en masse offre des perspectives de nouveaux usages dont nous n'entrevoyons qu'une infime partie. Encore faut-il être en mesure d'aller puiser ces données au sein des entreprises ou sur le Web, de les nettoyer, les structurer, les consolider et enfin les exploiter.

L'une des facettes les plus impressionnantes de l'exploitation massive des données est celle des modèles prédictifs. Ces systèmes sont en plein boom ! Sur Amazon ils vous invitent à consulter des articles qui peuvent vous intéresser. Sur Last.fm et Netflix ils vous recommandent les prochaines œuvres musicales ou cinématographiques à consulter. Sur vos téléphones portables ils anticipent le contenu du message que vous êtes en train de rédiger. Pour l'opération Miss Tweet (détail de l'analyse), nous les avons mis en œuvre pour prédire l'issue de l'élection de Miss France... et demain ?

Et bien justement, nous en parlerons vendredi 17 Mai 2013 lors de la 5e édition du Web2Day, avec au menu :

J'espère que vous viendrez nombreux pour échanger avec nous !

Voir l'article
Extraction de données d'OpenStreetMap hors ligne
23 déc. 2012

Dans ces différents billets, j'avais expliqué comment extraire des données depuis OpenStreetMap en interrogeant directement les serveurs. Dans ce billet, je propose une approche plus classique qui consiste à directement traiter les fichiers OSM à l'aide d'Osmosis.

Pour l'exercice, je propose de réitérer le travail d'extraction des arrêts de bus et de tramway mais sur la ville d'Angers. Pourquoi ? Parce qu'on me l'a demandé et que l'exercice est intéressant :)

Polygone de la ville d'Angers

L'objectif de l'exercice étant de travailler hors-ligne, nous allons travailler à partir d'exports de la base. Le plus connu est le planet.osm qui contient la totalité des données OpenStreetMap... mais c'est un peu gros pour nous. Nous allons nous limiter au fichier décrivant la région Pays-de-la-Loire disponible sur Geofabrik.

Bien entendu, la totalité du fichier ne nous intéresse pas. Nous allons devoir restreindre notre champs de recherche à la seule ville d'Angers, et pour ce faire nous allons définir le polygone géoréférencée qui délimite les frontières de la ville. La majorité des outils d'OpenStreetMap sont capable de filtrer les données selon des polygones définis dans un fichier au bon format.

Première tâche donc : définir le polygone de la ville d'Angers. Une solution consisterait à placer manuellement des points sur la carte OSM autour d'Angers de façon à construire la polygone. En tant que fainéant d'informaticien, je préfère tirer parti :

  1. de l'existence d'un script permettant de convertir une relation en polygone
  2. du superbe travail de tracé des limites administratives qui a été réalisé par la communauté.

Pour trouver l'identifiant de la relation associée aux limites administratives de la ville d'Angers, il nous suffit de passer par le service Nominatim qui propose un moteur de recherche de lieux basé justement sur les limites administratives d'OSM. La fiche de la ville d'Angers nous apprend qu'Angers est délimitée par la relation 1659048.

Plus qu'à mettre en œuvre le script getbound.pl :

$ ./getbound.pl -o ../polygone-angers.poly 1659048
Unable to load aliases from aliases.yml: Can't open 'aliases.yml' for input:
Aucun fichier ou dossier de ce type at /usr/lib/perl5/YAML/XS.pm line 46.
Downloading RelID=1659048...  Ok

Par souci de simplicité pour ceux qui veulent reproduire l'exercice, et parce que ça pourrait me servir, le fichier est disponible sur mon serveur (avec le polygone de la ville de Nantes).

Filtrage des arrêts avec Osmosis

Tout comme avec l'XAPI, il est possible avec Osmosis de filtrer le contenu qui nous intéresse, soit dans notre cas :

  • les nœuds correspondant à des arrêts de bus : highway=bus_stop
  • les nœuds correspondant à des arrêts de tramway : railway=tram_stop
  • les relations correspondant à des lignes de bus : route=bus
  • les relations correspondant à des lignes de tramway: route=tram

L'installation d'Osmosis ne pose en théorie pas de difficulté particulière : procédure d'installation sur le wiki.

Si jamais vous souhaitez par la suite retravailler sur la ville d'Angers, il peut être rentable de créer un fichier dédié regroupant l'ensemble des données. En effet, une fois décompressé, le fichier des Pays-de-la-Loire pèse un peu plus de 4Go... une masse de données pas facilement simple à manipuler.

$ bunzip2 pays-de-la-loire.osm.bz2
$ bin/osmosis --read-xml file="pays-de-la-loire.osm" --bounding-polygon file="polygone-angers.poly" --write-xml file="angers.osm"

Encore une fois, par souci de simplicité, ce fichier est directement disponible depuis mon serveur.

L'extraction des arrêts se fait relativement bien une fois les données préparées :

$ bin/osmosis \
    --read-xml file="angers.osm" \
    --tf accept-relations route=bus,tram \
    --tf accept-nodes highway=bus_stop,railway=tram_stop \
    --tf reject-ways \
    --used-node \
    --write-xml file="angers-transports.osm"

... ou encore si vous souhaitez travailler directement depuis l'extrait complet compressé :

$ bzcat pays-de-la-loire.osm.bz2 | osmosis-0.41/bin/osmosis --read-xml file=- \
   --bounding-polygon file="polygone-angers.poly"
   --tf accept-relations route=bus,tram \
   --tf accept-nodes highway=bus_stop,railway=tram_stop \
   --tf reject-ways \
   --used-node \
   --write-xml file=- | bzip2 > angers-transports.osm.bz2

Conversion en JSON

Le fichier OSM est le format de choix pour une utilisation avec les outils d'OpenStreetMap. Toutefois, il est beaucoup moins approprié à une utilisation dans le cadre d'un service web ou d'une application mobile par exemple.

J'ai écrit un petit script Python qui prend en entrée le fichier osm compressé et le transforme en un fichier JSON listant les différents arrêts de bus et de tramway ainsi que les lignes associées.

Le résultat pour la ville d'Angers est disponible sur mon serveur.

Voir l'article
Dictanova... un an plus tard
04 déc. 2012

Dans ce billet je partageais avec vous mes premières semaines en tant qu'entrepreneur. Sacré teasing puisque je ne crois pas avoir publié quoi que ce soit depuis sur le sujet ! Mes plus plates excuses à ceux que ça intéresse... mais en réalité on essaie de publier nos aventures directement sur le blog de Dictanova.

Si je devais résumer cette première année en quelques points :

  • Vache ça prend un temps fou, je n'ai plus le temps de ne rien faire... même pas publier de billets sur ce blog !
  • Entrepreneur c'est le job le plus horrible du monde : des heures de boulot qui ne paient pas toujours, des plannings complètement instables, une vie sociale en mode bipolaire où l'on ne sait plus très bien si on est en mode pro ou perso.
  • Mais je ne voudrais pas faire autre chose :)

Et de manière un peu plus factuelle :

Bref l'aventure continue...

Voir l'article
Cartopartie Ouganda ce mercredi 5 septembre à Nantes
03 sept. 2012

La Cantine Numérique et les contributeurs OpenStreetMap nantais organisent une session d'initiation aux outils de cartographie et notamment JOSM :

le Mercredi 5 septembre, à 18h , à La Cantine de Nantes

En participant à cet atelier, non seulement vous apprendrez à utiliser JOSM (l'outil ultime du contributeur OSM) mais en plus vous ferez votre BA puisque l'objet de l'atelier est de cartographier une partie de l'Ouganda à partir de cartes satellites afin de faciliter le travail des organisations humanitaires sur place !

Venez nombreux :)

Voir l'article
Exporter des éléments géoréférencés depuis OSM avec JOSM
21 juil. 2012

La base de données OSM est une formidable source d'information concernant les lieux et autres artefacts composant nos villes et nos campagnes. Malheureusement, les gens perçoivent souvent le projet au travers uniquement des rendus de cartes et passent ainsi à côté de toute cette richesse. Il faut dire qu'interroger la base de données n'est pas chose aisée, les diverses API ne sont pas forcément facile à prendre en main, en particulier lorsque l'on n'est pas développeur. Je me suis moi-même cassé plusieurs fois les dents dessus lors d'hackatons.

Je propose dans ce billet une méthode assez simple et directe pour récupérer des données depuis OSM en utilisant le système de filtres de JOSM.

La méthode nécessite 3 étapes :

  1. Télécharger les données de la zone qui nous intéresse ;
  2. Créer un filtre pour sélectionner les objets d'intérêt ;
  3. Exporter les objets sélectionnés.

Petite illustration avec les objets touristiques de la ville de Changé !

1- Télécharger les données

La première étape consiste à lancer JOSM et télécharger les données de la zone couvrant la ville.

La définition de la zone à télécharger se fait dans une fenêtre dédiée accessible depuis le bouton Télécharger les données de la carte :

josm-download-data-from-zone.png

Il faut ensuite sélectionner à la souris la zone à télécharger après s'être potentiellement déplacer sur la carte en maintenant le clic droit appuyé :

josm-select-zone.png

Une fois la sélection effectuée, cliquer sur Télécharger. JOSM va alors récupérer toutes les données de la zone.

Attention, si votre zone est trop conséquente, il ne sera pas possible de toute récupérer en une fois. Il faudra télécharger une sous partie de la zone puis télécharger le reste par morceaux. C'est une des limites de cette approche.

2- Créer un filtre

La seconde étape consiste à filtrer parmi toutes les données récupérées uniquement celles qui nous intéressent.

Il vous faut tout d'abord faire apparaître la fenêtre de configuration des filtres : Fenêtres > Filtrer ou bien Alt+Maj-F.

Pour l'exemple nous allons récupérer toutes les installations touristiques ou de loisirs de la ville de Changé, soit grosso modo les tags :

Nous allons donc créer un nouveau filtre :

josm-add-filter.png

Les filtres sont définis comme des espèces d'expressions régulières sur les tags des objets, soit pour notre exemple :

amenity=*|leisure=*|sport=*|tourism=*|shop=*

josm-create-filter.png

Une fois le filtre créé il apparaît dans la liste des filtres. Par défaut le système cache les éléments acceptés par le filtre, nous souhaitons réaliser l'inverse... ça tombe bien il y a une case à cocher pour ça :

josm-invert-filter.png

La carte n'affiche désormais plus que les installations géolocalisées qui nous intéressent.

3- Exporter les données

La troisième et dernière étape consiste à exporter les données concernant les objets filtrés.

Il suffit de sélectionner tous les objets promus par le filtre en dézoomant suffisamment et en sélectionnant la zone à l'aide de la souris. Les éléments sélectionnés apparaissent alors en rouge :

josm-select-filtered.png

Une fois les éléments sélectionnés, il faut les copier (Ctrl-C), créer un nouveau calque de données (Ctrl-N) et copier les éléments vers ce nouveau calque (Ctrl-V) :

josm-copy-paste-in-layer.png

Finalement, nous pouvons exporter ce nouveau calque de données qui ne contient que les éléments qui nous intéressent : Fichier > Enregistrer sous en s'assurant que le calque sélectionné soit le bon. Deux format d'enregistrement sont intéressants :

Les deux sont basés sur XML et sont peu utilisés en dehors des outils de SIG. Il faudra passer par des outils de conversion pour les intégrer dans l'outil de visualisation votre choix.

Voir l'article
Migrer une partie d'un dépôt subversion vers un autre dépôt
03 juin 2012

Avec l'expérience, je me rends compte que la structuration d'un dépôt subversion n'est jamais satisfaisante et que l'on se retrouve continuellement à vouloir bouger des choses, fusionner certains projets, en éclater d'autres... Parfois il est préférable de repartir proprement sur un nouveau dépôt, sans perdre l'historique des révisions accumulés dans l'ancien.

Import / Export

L'exportation puis l'importation d'un dépôt subversion s'opére simplement à l'aide de la commande svnadmin exécuté depuis l'hôte hébergeant le dépôt :

Pour l'exportation :

svnadmin dump /path/to/svn/repository/ > my-repository.svn.dump

Pour l'importation :

svnadmin load /path/to/svn/repository/ < my-repository.svn.dump

Facile !

Limiter l'import/export à une partie du dépôt

Lorsque l'on ne souhaite récupérer qu'une partie du dépôt, il faut se tourner vers l'utilitaire svndumpfilter. Celui-ci permet de limiter l'export à certains répertoires uniquement (mode include) ou bien à supprimer certains répertoires (mode exclude). Notez bien qu'il n'est pas possible de combiner ces deux modes.

Si par exemple vous ne souhaitez exporter que le projet /trunk/genesis-commons/, procédez comme suit :

svnadmin dump /path/to/svn/repository/ | svndumpfilter include --drop-empty-revs --renumber-revs /trunk/genesis-commons/ > my-project.svn.dump

Les options --drop-empty-revs et --renumber-revs permettent de supprimer les révisions qui ne concernent pas le projet et de renuméroter les révisions restantes de sorte à ce qu'elles se suivent.

Lors de l'export vous pouvez obtenir des messages d'erreurs du type Invalid copy source path. Les causes de ce type de message sont le plus couramment qu'une partie du projet que vous exportez a été copiée depuis une autre partie du dépôt qui n'est pas exportée. Le plus simple, mais pas forcément le plus heureux, est d'inclure la partie originale de laquelle provient l'élément manquant dans l'export.

Une fois l'export effectuée, il y a fort à parier que vous souhaitiez modifier le répertoire de destination de votre projet dans le dépôt vers lequel vous allez l'importer. Pour cela pas de solution clé en main : il faut éditer le fichier d'export ! Une petite indication pour automatiser le processus : les chemins des ressources sont préfixés par Node-path:. Il est donc possible d'automatiser en partie le processus avec sed :

sed -i 's@Node-path: my/previous/resource/path/\(.*\)@Node-path: my/new/path/\1@' my-project.svn.dump

Attention également à prendre en compte les instructions Node-copyfrom-path:.

En comparaison le chargement de l'export est des plus direct :

svnadmin load /path/to/svn/repository/ < my-project.svn.dump

Si toutefois vous souhaitez importer le projet dans un sous-répertoire particulier du dépôt :

svnadmin load /path/to/svn/repository/ --parent-dir my/project/new/directory < my-project.svn.dump

Ressources

Je n'aurais certainement pas été en mesure d'écrire ce billet sans les ressources ci-dessous :

Voir l'article
Plantage de Tomcat : java.lang.OutOfMemoryError: PermGen space
19 mai 2012

Depuis plusieurs semaines je constate des plantages réguliers de Tomcat avec pour seule trace dans les logs l'abscon message :

java.lang.OutOfMemoryError: PermGen space

J'ai bêtement pensé, comme le laissait présager le message, que mes applications étaient à l'étroit dans le (petit ?) giga de mémoire que je réservais à la JVM. Mais un passage à 2Go n'y a rien changé. J'ai alors soupçonné mes développements en considérant une fuite mémoire dans une de mes applications. À force d'essais erreurs, je me suis rendu compte que le problème survenait non pas lors de l'utilisation de mes applications mais lors de l'utilisation de JIRA.

Les gens d'Atlassian sont plutôt d'excellents ingénieurs, j'ai eu un peu de mal à considérer qu'ils avaient laisser une bête fuite mémoire dans leur programme vedette ! Par contre, il est vrai que JIRA est une application bien plus complexe et lourde que toutes les autres applications qui tournent sur ce serveur.

L'analyse du problème m'a permis d'en découvrir un peu plus sur le fonctionnement de la JVM. La JVM utilise trois tas différents (heap en anglais) :

  • Un tas dit de jeune génération qui stocke les objets créés et aussitôt détruits par le garbage collector,
  • Le tas d'ancienne génération stocke les objets qui persistent en mémoire,
  • Enfin, le tas de génération permanente (permgen) qui stocke les définitions des classes, des méthodes et les métainformations associées.

Mon souci s'explique par le fait que :

  1. JIRA utilise un très grand nombre de classes et nécessite par conséquent beaucoup d'espace dans le permgen,
  2. L'espace mémoire du permgen est confiné et n'exploite pas l'espace alloué aux autres tas avec les options -Xmx et -Xms.

Cette configuration a donc provoqué dans mon système un débordement mémoire du tas quand bien même l'espace mémoire alloué à la JVM était sous utilisé. Le sens du message PermGen space prend alors tout son sens :)

Au final la solution est assez simple, il suffit d'augmenter l'espace mémoire alloué au permgen avec l'option dédiée -XX:MaxPermSize, soit dans mon cas : -XX:MaxPermSize=256m !

Voir l'article
Stage OpenStreetMap à Nantes
02 mars 2012

La Biliothèque de Rezé organise un stage sur 3 jours dédié à la cartographie sur Internet et en particulier OpenStreetMap. Si vous êtes de la région nantaise et que vous avez toujours souhaité vous essayer à OSM sans n'avoir jamais osé, c'est l'occasion !

Le stage est animé par deux contributeurs à OSM et devrait être passionnant. Il se tiendra du 13 au 16 mars 2012 de 19h à 21h à la Médiathèque Diderot.

Attention, l'inscription est obligatoire pour pouvoir participer. Pour en savoir plus : le petit flyer de présentation.

Voir l'article
Premiers pas avec UIMA AS
09 janv. 2012

UIMA offre un cadre de développement structurant pour la mise au point de chaînes de traitement de l'information non structurée. S'il permet simplement de déployer des chaînes complexes et tirer parti de la puissance de calcul des processeurs multicœurs, l'ordonnanceur -- le CPM -- a plusieurs limites :

  • Il n'est pas aisé de déployer une chaîne sur plusieurs machines ;
  • L'affectation de ressources se fait au niveau de la chaîne (CPE) et non au niveau des composants alors que le coût d'exécution de ces derniers est fortement variable ;
  • Les chaînes ne peuvent pas directement traiter un flux continu de données.

Le développement d'UIMA AS cherche à répondre à ces limitations.

Principe général

UIMA AS est développé en remplacement du Collection Processing Manager (CPM) dans le but d'offrir de meilleures capacités de flexibilités et montée en charge. UIMA AS ne remplace nullement UIMA, et les composants développés pour UIMA peuvent aussi bien fonctionner dans une chaîne UIMA AS. AS profite du système d'Aggregate pour organiser les composants en chaînes de traitement, tirant parti au passage des capacités des flow controllers.

L'approche CPM (UIMA classique)

Lorsqu'une chaîne de traitement UIMA est déployée par le CPM, la configuration suivante se met en place :

  • le CPM instancie autant de processing pipelines (PP, combinaison séquentielle de composants) que nécessaire, chaque PP contenant une seule et unique instance de chaque composant ;
  • le Collection Reader (CR) est instancié dans un seul thread (pas de parallélisation possible) ;
  • les CAS produits par le CR sont stockés dans une queue tampon avant d'être distribués vers les différents PP qui ne peuvent qu'en traiter un à la fois : un CAS en sortie d'un composant est directement passé en entrée du composant suivant.

Ce fonctionnement est décrit par le schéma suivant tiré de la documentation d'Apache UIMA si ce n'est que les cas consumers n'existent plus en tant qu'entités particulières mais sont des composants comme les autres (et donc instanciés dans les PP) :

cpe-detail.png

L'approche UIMA AS

Les chaînes dans UIMA AS sont déployées sous forme de services auxquels peuvent se connecter des clients. Les clients envoient des requêtes au service puis attendent le résultat du traitement. L'interface entre les clients et les services se fait au travers d'un système de messagerie asynchrone :

  • les requêtes des clients sont stockées dans une queue tampon en entrée du service similaire à la queue du PP pour le CPM ;
  • les résultats des traitements sont renvoyées dans une queue tampon propre à chaque client.

Ce fonctionnement est décrit par le schéma ci-dessous tiré de la documentation d'Apache UIMA AS. Il permet d'instancier une seule fois une chaîne de traitement et de lui soumettre un flux continu de données à traiter en provenance potentiellement de plusieurs sources.

uima-as-arch.png

Le système de messagerie asynchrone prend en charge la distribution des requêtes entre différentes instances potentielles du service permettant ainsi une montée en charge à chaud. Il suffit de déployer une nouvelle instance du service et lui indiquer de se connecter à la queue tampon d'entrée pour augmenter les capacités de traitement. Le système de messagerie utilisé par défaut, ActiveMQ, ayant la capacité de communiquer au travers du réseau, ledit service peut tout à fait être déployé sur une machine distante.

Une autre force de UIMA AS est que ce système de queue tampons et de routage des CAS au travers d'un système de messagerie asynchrone peut être mis en place au sein même du service entre les différents composants. Pour cette première prise en main évitons tout de même de compliquer les choses...

Mise en place de l'environnement

La mise en place d'une chaîne de traitement UIMA AS comparable à un CPE nécessite trois éléments : le service UIMA AS en charge du traitement, le client qui soumet les données et récupère les résultats du traitement et le système de messagerie coordonne la communication entre ces deux premiers.

La mise en place d'un service UIMA AS nécessite, outre le développement des composants, un descripteur de déploiement (deployment descriptor). Afin de simplifier son écriture dans la prochaine section de ce billet, il est préférable d'installer les plugins Eclipse dédiés.

Un certain nombre de dépendances sont nécessaires pour la compilation et l'exécution d'un service et d'un client UIMA AS, a minima :

Finalement, il est nécessaire d'installer un système de messagerie de type JMS. Le plus simple est d'utiliser celui proposé par défaut : Apache ActiveMQ. Une version est distribuée dans l'archive UIMA AS. Toutefois, si vous êtes sous un environnement Linux, il sera plus aisé et pérenne du paquetage de votre distribution.

Ainsi pour Debian Wheezy, installez le paquet :

sudo apt-get install activemq

puis activez l'instance par défaut :

cd /etc/activemq/instances-enabled/
sudo ln -s ../instances-available/main/

et lancez enfin le système :

sudo /etc/init.d/activemq start

Création et déploiement d'un service

Descripteur de déploiement

Le déploiement d'un service UIMA AS nécessite deux descripteurs :

  • Un descripteur de composant qui définit l'aggregate qui sera déployé ;
  • Un descripteur de déploiement qui décrit comment l'aggregate sera déployé.

Pour l'exercice nous déploierons un composant de prétraitement : découpage en phrases, en mots et étiquetage des rôles grammaticaux. Nous utiliserons pour cela les composants addons distribués avec UIMA : le WhitespaceTokenizer et le HMMTagger. L'écriture de l'aggregate ne pose pas de difficulté particulière, il s'agit d'un composant UIMA classique sans aucune spécificité que nous nommerons POSTagger.xml.

Le descripteur de déploiement est une nouveauté de UIMA AS. Il est disponible dans l'assistant de création d'un nouveau fichier sous la catégorie UIMA aux côtés des autres descripteurs, si tant est que vous avez correctement installé les plugins UIMA AS. Nous le nommerons POSTagger-Service.xml.

Création d'un nouveau descripteur de déploiement UIMA AS

Le descripteur de déploiement se compose de deux onglets : un onglet de configuration générale (Overview) et un onglet de configuration du déploiement (Deployment Configurations). Il faut préciser dans l'onglet de configuration générale le nom de la queue de messagerie sur laquelle le service sera joignable (Name for input queue) et le nom de l'aggregate à déployer comme service (Top analysis engine descriptor). Ces deux champs sont mis en valeur dans la capture d'écran ci-dessous.

Complétion de la configuration générale du déploiement

Par défaut il n'est pas nécessaire d'intervenir sur la configuration du déploiement. Le service sera alors déployé tel qu'il l'aurait été sous forme d'un CPE, c-à-d sans contrôle particulier sur les composants qui composent l'aggregate. Pour l'exercice, nous allons le déployé comme un aggregate UIMA AS, c-à-d que chaque composant de l'aggregate est lui-même déployé comme un service connecté aux autres composants par le système de messageries et de queues.

Ce déploiement nécessite simplement de cocher la case Run as AS aggregate. Une fois coché, les différents composants qui constituent l'aggregate sont accessibles pour une configuration propre.

Configuration du déploiement comme un AS Aggregate

Pour simplifier nous considérerons qu'ils sont tous déployés en local et dans la même JVM. Nous allons simplement demander de déployer trois instances du HMMTagger étant donné que c'est le composant le plus coûteux. Il suffit pour ce faire de sélectionner le composant HMMTagger dans la liste puis de positionner le paramètre Number of replicated instances sur 3.

Configuration du nombre d'instances déployées pour chaque service

Écriture du client asynchrone

Une fois le descripteur de déploiement écrit, il ne reste plus qu'à écrire le code du client asynchrone qui va s'y connecter. La documentation d'UIMA AS décrit en détail un cas d'utilisation duquel ce billet s'inspire.

Tout d'abord, toujours dans le cadre de l'exercice, nous faisons le choix que le service soit déployé par l'application elle-même :

public static final String DD2SPRINGXSLTFILEPATH = 
	"/LIBS-APPS/UIMA/apache-uima-as-2.3.1/bin/dd2spring.xsl";
public static final String SAXONCLASSPATH = 
	"file:/LIBS-APPS/UIMA/apache-uima-as-2.3.1/saxon/saxon8.jar";
public static final String DEPLOYMENTDESCRIPTOR =
	"desc/POSTagger-Service.xml";
...
// Create Asynchronous Client API
BaseUIMAAsynchronousEngine_impl uimaAsEngine = 
		new BaseUIMAAsynchronousEngine_impl();
 
///////////////////////////////////////////////////// SERVICE DEPLOYING
System.out.println("Deploying service");
// Create a Map to hold required parameters
Map<String, Object> servCtxt = new HashMap<String, Object>();
servCtxt.put(UimaAsynchronousEngine.DD2SpringXsltFilePath, 
		DD2SPRINGXSLTFILEPATH);
servCtxt.put(UimaAsynchronousEngine.SaxonClasspath,
		SAXONCLASSPATH);
String serviceId = uimaAsEngine.deploy(DEPLOYMENTDESCRIPTOR, servCtxt);
System.out.println("\t...Service deployed !");

Une fois le service déployé, nous allons configurer un client asynchrone pour s'y connecter et soumettre des requêtes. Il est important ici que :

  • le paramètre UimaAsynchronousEngine.ServerUri corresponde à l'adresse du gestionnaire de messagerie (broker) spécifié auparavant dans le descripteur de déploiement (nous avons laissé la valeur par défaut ${defaultBrokerURL}, nous verrons après comment la spécifier à l'exécution) ;
  • le paramètre UimaAsynchronousEngine.Endpoint doit correspondre au nom de la queue sur laquelle le service est joignable.
///////////////////////////////////////////////////// SERVICE EXECUTION
System.out.println("Preparing for execution");
// Create map to pass server URI and Endpoint parameters
Map<String, Object> appCtxt = new HashMap<String, Object>();
appCtxt.put(UimaAsynchronousEngine.ServerUri, "tcp://localhost:61616");
appCtxt.put(UimaAsynchronousEngine.Endpoint, "POSTaggerQueue");
appCtxt.put(UimaAsynchronousEngine.CasPoolSize, 2);
// Initialize
uimaAsEngine.initialize(appCtxt);
uimaAsEngine.addStatusCallbackListener(new MyStatusCallbackListener());
// Get an empty CAS from the Cas pool
CAS cas = uimaAsEngine.getCAS();
System.out.println("\t...CAS retrieved");
// Initialize it with input data
cas.setDocumentText("Un exemple de texte...");
cas.setDocumentLanguage("fr");
// Send CAS to service for processing
uimaAsEngine.sendCAS(cas);
System.out.println("\t...CAS sent");

Il est intéressant d'observer le fonctionnement du client :

  1. L'initialisation consiste, outre la configuration des différents paramètres précités, à définir l'instance StatusCallbackListener qui sera chargée de consommer les résultats du traitement par le service (toute l'asynchronicité du client réside dans ce fonctionnement) ;
  2. L'envoi d'une demande de traitement passe d'abord par la demande d'un CAS disponible par la méthode getCAS(). Une fois le CAS obtenu, il est initialisé comme le ferait un Collection Reader, puis soumis au système par la méthode sendCAS().

Il ne reste alors plus qu'à implémenter le StatusCallbackListener et plus particulièrement la méthode entityProcessComplete appelée lorsque le traitement d'un CAS est terminé :

static class MyStatusCallbackListener extends UimaAsBaseCallbackListener {
 
	@Override
	public void entityProcessComplete(CAS aCas, EntityProcessStatus aStatus) {
		System.out.println("Entity process complete.");
		// Handle errors
		if ( (aStatus != null) && aStatus.isException() ) {
			List<Exception> exceptions = aStatus.getExceptions();
			for(Exception e: exceptions) {
				e.printStackTrace();
			}
			return;
		}
		// Process CAS
		try {
			System.out.println("Concepts identified:");
			JCas jcas = aCas.getJCas();
			FSIterator<Annotation> it = 
					jcas.getAnnotationIndex(T_Token.type).iterator();
			while( it.hasNext() ) {
				T_Token token = (T_Token) it.next();
				System.out.println("\t+ POS:" + token.getPos());
			}
		} catch (CASException e) {
			System.out.println("Problem getting a JCas !");
		}
	}
 
}

Exécution

Une fois tout ce travail réalisé, il ne reste plus qu'à exécuter le tout. Il est nécessaire d'ajouter toutes les dépendances nécessaires à l'exécution soit toutes les bibliothèques UIMA et UIMA AS ainsi que les nombreuses dépendances Spring.

Il est également nécessaire de spécifier, par le biais d'une variable de JVM l'adresse du gestionnaire de messagerie que nous avons positionné jusqu'alors à ${defaultBrokerURL}. Pour l'exercice, nous utilisons un broker ActiveMQ local. Le paramètre de la JVM est donc le suivant :

-DdefaultBrokerURL=tcp://localhost:61616

À partir d'ici le service devrait se déployer et le client asynchrone s'y connecter sans accroc. Si vous rencontrez des erreurs du type Connection refused c'est très probablement que votre système de messagerie ne tourne pas ou bien que les noms de queues précisés dans le descripteur et pour le client sont différents.

Voir l'article
Installation des plugins UIMA-AS sous Eclipse Indigo
05 janv. 2012

Au sein de Dictanova, nous avons des besoins importants en termes de distribution de la charge de calcul. Le CPM classique d'UIMA (l'organe chargé de l'ordonnancement des traitements) ne répond pas suffisamment à nos besoins et nous nous tournons donc vers UIMA AS (pour UIMA Asynchronous Scaleout) qui offre des possibilités de montées en charge beaucoup plus importantes en permettant notamment de déployer les Analysis Engine dans plusieurs JVM et sur plusieurs machines.

Je décris dans ce billet la procédure que j'ai mise en oeuvre pour installer les plugins UIMA AS sous Eclipse Indigo.

Le problème

Dans le meilleur des mondes, l'installation des plugins UIMA AS devrait se dérouler sans encombre en utilisant le gestionnaire de plugins d'Eclipse. Cependant, si comme moi vous n'aviez jusqu'à présent que les plugins UIMA classiques d'installés et que vous souhaitez installer les plugins UIMA AS, Eclipse vous informera que c'est impossible car une dépendance n'a pas été trouvée.

L'origine de ce problème est la récente release de UIMA 2.4.0, et la mise à jour dans cette version 2.4.0 des plugins alors que les plugins UIMA AS dépendent d'une version 2.3.x. Il est donc nécessaire de revenir à une version 2.3.1 des plugins UIMA avant d'installer les plugins UIMA AS.

Rétrograder les plugins Eclipse Apache UIMA en version 2.3.1 (downgrade) pour installer le plugin UIMA AS

Il n'est pas directement possible de revenir à une version antérieure d'un plugin. Il est nécessaire dans un premier temps de désinstaller le plugin, puis d'installer la version antérieure.

Pour désinstaller le plugin, il faut se rendre dans le menu Aide > À propos d'Eclipse > Détails de l'installation (Help > About Eclipse > Installation Details) comme le montrent les captures d'écran ci-dessous :

eclipse-about-install.png

eclipse-installation-details.png

La fenêtre liste les features actuellement installées. Sélectionnez celles qui correspondent à UIMA puis cliquez sur Désinstaller (Uninstall).

Une fois la désinstallation effectuée et après avoir redémarrer Eclipse, il est possible d'installer la version 2.3.1 des plugins UIMA classiques et dans le même temps le plugin UIMA AS.

Rendez-vous dans le menu Aide > Ajout de nouveaux logiciels (Help > Install new software), indiquez à l'assistant d'utiliser tous les sites à sa disposition et décochez la case indiquant de ne montrer que les dernières versions comme l'illustre la capture d'écran ci-dessous :

eclipse-install-plugins.png

Il suffit ensuite de sélectionner les versions 2.3.1 des différents plugins : UIMA Runtime, UIMA tools et UIMA AS. L'installation devrait alors se dérouler sans accrocs.

Voir l'article
Ouverture des données publiques à Nantes
22 nov. 2011

Ça y est c'est officiel, Nantes a lancée sa plateforme OpenData. Ça s'est passé ce lundi 21 novembre 2011 à la Cantine --- comme tous les évènements cools qui ont lieu sur Nantes, et je ne dis pas ça que parce que je suis fan de la Cantine Nantaise, d'Atlantic 2 et du quatuor de choc Adrien/Magali/Florent/Lucie !

Le nombre de jeu de données est pour l'instant restreint, mais un jeu en particulier a attiré les contributeurs OSM.

Avant toute chose, merci à Nantes/Nantes-Métropole et surtout aux équipes qui en ont bavé pour tenir les promesses de J.M. Ayrault (c'est bien beau de faire des promesses politiques, mais s'il n'y a pas derrière des équipes qui tiennent la route pour mener les projets à bien, on ne va nul part). Merci bien entendu aussi à LiberTIC et en particulier à Claire pour le formidable boulot accompli ;)

Je crois que j'ai fait le tour des remerciements (non ce billet n'est pas sponsorisé...).

Je disais donc qu'un jeu de données avait immédiatement attiré l'attention des contributeur : le fichier des adresses postales de Nantes Métropole. Un fichier de 12 Mo pour des adresses postales... on s'est demandé ce qu'il pouvait bien pouvoir y avoir à l'intérieur. C'est somme tout assez simple et évident : les coordonnées GPS de toutes les boîtes aux lettres de l'agglomération Nantaise et les adresses associées.

Outre optimiser la tournée des facteurs, ces données vont permettre aux contributeurs OSM de vérifier la couverture de Nantes Métropole en croisant les données de ce fichier et celles déjà sur OSM (Open Street Map pour ceux qui se demandent bien la signification de cet acronyme depuis le début du billet... et si vous voulez en savoir plus sur ce super projet, c'est par ici).

Une histoire de bbox

Le fichier Adresses_nm.csv contient donc les coordonnées GPS de toutes les boîtes aux lettres de la métropole nantaise et plus précisément :

  • l'adresse (ex : 3 Rue des Quinze Sillons)
  • la commune associée (ex : ST-SEBASTIEN)
  • le mot directeur, c-à-d le mot le plus différenciateur de l'adresse (ex : Quinze)
  • le numéro seul (ex : 3)
  • le code rivoli de la voie (ex : 1176)
  • le code postal (ex : 44230)
  • et la longitude et latitude.

Dans un premier temps, nous allons utiliser ces données pour définir une bounding box (boîte contenante) pour chacune des communes de l'agglomération. L'idée est très simple : on parcourt toutes les entrées du fichier CSV en distribuant les coordonnées GPS par commune et en ne conservant que le min et le max des latitudes et longitudes.

Quelques lignes de python :

import csv
reader = csv.DictReader(open('Adresses_nm.csv', 'rb'), delimiter=';')
bbox_communes = {}
for row in reader:
	commune   = row["NOMCOM"]
	latitude  = float(row["LAT_WGS84"].replace(",", "."))
	longitude = float(row["LON_WGS84"].replace(",", "."))
	if not bbox_communes.has_key(commune):
		bbox_communes[commune] = {
			"minlat": latitude,
			"maxlat": latitude,
			"maxlon": latitude,
			"minlon": latitude}
	else:
		if latitude > bbox_communes[commune]["maxlat"]:
			bbox_communes[commune]["maxlat"] = latitude
		if latitude < bbox_communes[commune]["minlat"]:
			bbox_communes[commune]["minlat"] = latitude
		if latitude > bbox_communes[commune]["maxlon"]:
			bbox_communes[commune]["maxlon"] = longitude
		if latitude < bbox_communes[commune]["minlon"]:
			bbox_communes[commune]["minlon"] = longitude

... et voici les bbox :

Compter les communes

Dans un second temps nous allons vérifier que toutes les communes qui sont présentes dans le fichier sont bien déclarées dans OSM, et plus précisément déclarées dans les bbox calculées. Le plus simple pour ce faire est d'utiliser l'XAPI, soit pour Rezé par exemple, une requête de ce style : http://open.mapquestapi.com/xapi/api/0.6/*%5Bplace=*%5D%5Bbbox=-1.585649046124,47.152330025829,-1.521063232296,47.196722167190%5D. Ceci nous permet de nous rendre compte qu'OSM est très précise puisqu'en plus de Rezé la base de données contient de nombreux lieux-dits (ou quartiers) : Trentemoult, Le Port Au Blé, Saint Paul, La Houssais...

Encore une fois OSM est plus précis que les données officielles ;)

Trêve de digression, nous allons nous concentrer sur les communes uniquement et bien sûr automatiser le processus :

import urllib2
from lxml import etree
import sys
for k in bbox_communes.keys():
	sys.stdout.write(u"%s..." % unicode(k, "ISO8859"))
	maxlon = bbox_communes[k]["maxlon"]
	minlon = bbox_communes[k]["minlon"]
	maxlat = bbox_communes[k]["maxlat"]
	minlat = bbox_communes[k]["minlat"]
	xapiurl = "http://open.mapquestapi.com/xapi/api/0.6/node[place=*][bbox=%f,%f,%f,%f]" % (minlon,minlat,maxlon,maxlat)
	try:
		xml = urllib2.urlopen(xapiurl).read()
		dom = etree.XML(xml)
		for node in dom.iter(tag="node"):
			values = unicode("/".join([t.get("v") for t in node.iter(tag="tag")]))
			sys.stdout.write(u"%s=%s" % (node.get("uid"), values))
	except urllib2.HTTPError:
		sys.stdout.write("error querying (%s)" % (xapiurl))
	sys.stdout.write("\n")

Résultat, il va falloir homogénéiser un peu les tags utilisés pour les communes :)

Communeplace ?uid
REZEtown58255
ORVAULT # #
BOUGUENAIS # #
SAINT-AIGNAN-GRANDLIEU # #
SAINT-JEAN-DE-BOISEAUvillage58255
INDRE ? ?
ST-HERBLAIN ? ?
COUERON ? ?
SAUTRONvillage58255
SAINTE-LUCE-SUR-LOIRE ? ?
VERTOU # #
LA-MONTAGNE # #
BRAINSvillage58255
BOUAYE ? ?
LES-SORINIERES ? ?
NANTEScity212207
ST-SEBASTIEN # #
MAUVES-SUR-LOIREvillage58255
SAINT-LEGER-LES-VIGNESvillage58255
LA-CHAPELLE-SUR-ERDRE ? ?
THOUARE-SUR-LOIRE ? ?
BASSE-GOULAINE ? ?
CARQUEFOUtown58255
LE-PELLERIN ? ?

Prochaine étape, vérifier les noms des rues... et ensuite insertion des adresses dans la base ;)

Voir l'article
Travaux Pratique TALN - Contexte syntaxique
21 nov. 2011

Les séances précédentes ont été consacrées à l'analyse lexicale et morphologique. Il est temps de se détacher de la dimension lexicale des textes pour tendre vers la dimension syntaxique.

Les rôles grammaticaux

Tous les mots n'ont pas la même fonction dans la phrase, chaque mot a un rôle grammatical propre qui indique la façon dont il participe à la construction du sens. Ainsi les déterminants (le, la, les, ...) ne jouent pas un rôle aussi crucial que les noms communs ("champion", "médaille", "concours", ...). Ils font parti de l'outillage grammatical qui permet de faire collaborer les mots afin de produire du sens.

Dans le cadre de cette séance de TP, je propose de travailler sur les catégories grammaticales suivantes :

  • l'article, le déterminant : le, la, les, l', un, une, des, ...
  • le nom : médaille, champion, podium, éléphant, ...
  • l'adjectif : grande, téméraire, puissant, ordonnée, ...
  • le pronom : je, me, moi, se, ...
  • le verbe : manger, courir, être, penser, ...
  • l'adverbe : lentement, doucement, rapidement, ...
  • la préposition : à, en, sur, sous, sans, avec, ...
  • la conjonction : mais, ou, et, donc, ...

Les mots de chacune de ces catégories ont une fonction propre dans la phrase : qualifier le nom, faire référence à une personne ou un objet, décrire l'action ou l'état, coordonner des propositions... Si vous voulez en savoir plus, vous pouvez aller lire ceci.

Il est possible de lister de manière exhaustive (ou suffisamment exhaustive) les mots de certaines catégories grammaticales : les déterminants, les pronoms, les conjonctions et dans une certaine mesure les prépositions. Toutefois, il est très difficile de lister les noms, les verbes, les adjectifs ou encore les adverbes ; et ce pour deux raisons : les mots pouvant prendre ces rôles sont très nombreux et il est possible d'en générer une quasi-infinité de nouveaux, par composition notamment.

Lister les mots des catégories déterminant, pronom, conjonction et préposition

Même si l'ont peut donner une liste quasi-exhaustive des mots qui appartiennent aux catégories grammaticales déterminant, pronom, conjonction et préposition ; il peut être difficile de réaliser ces listes de tête.

Encore une fois, le corpus peut-être d'un grand secours. En effet, du fait de la combinaison de leur nombre réduit (par comparaison aux noms et verbes par exemple) et de leur rôle primordial dans la phrase, ces mots sont sont parmi les mots les plus fréquents. Un simple comptage d'occurrence permet donc d'en découvrir un certain nombre :

import codecs
# Chargement du corpus prétraité (découpé en mots)
fh = codecs.open("Discours-Sarkozy-v20111025.preproc.txt", "r", "utf-8")
words   = fh.read().split()
fh.close()
# Comptage des mots
from nltk import FreqDist
fd = FreqDist()
for w in words:
	fd.inc(w.lower())
# Affichage du Top 50 avec leur nombre d'occurrence
fd.items()[:50]
 
[(u',', 111161), (u'de', 83656), (u'.', 76146), (u'la', 57608), 
(u"l'", 41454), (u'et', 38519), (u'le', 37246), (u'les', 35018),
(u'que', 33457), (u'\xe0', 31767), (u'est', 29416), (u'des', 27233),
(u"d'", 26458), (u'je', 21483), (u'qui', 21432), (u'en', 21317),
(u'pas', 20128), (u'un', 19782), (u'pour', 18374), (u'il', 17286),
(u'une', 16512), (u'nous', 16031), (u"c'", 15082), (u'a', 14891),
(u'dans', 13862), (u'on', 13849), (u'vous', 13799), (u'du', 13605),
(u'ne', 13502), (u'ce', 13231), (u"qu'", 12629), (u"n'", 12400),
(u'au', 9723), (u'plus', 9494), (u'y', 7980), (u'sur', 7845), 
(u'france', 7687), (u'mais', 7638), (u'avec', 6869), (u':', 6556),
(u"j'", 6408), (u'se', 5870), (u"s'", 5809), (u'sont', 5791), 
(u'par', 5724), (u'cela', 5536), (u'cette', 5430), (u'ont', 5230),
(u'aux', 5145), (u'si', 5009)]

Cette approche combinée à notre connaissance de la grammaire française nous permet de constituer assez rapidement la liste des mots de chacune des catégories déterminant, pronom, conjonction et préposition.

Cette approche n'est toutefois pas satisfaisant pour les "puristes", notamment car le corpus est loin d'être représentatif de la langue mais également car nous nous limitons ici aux mots et négligeons les locutions.

Voici néanmoins une proposition de liste construite de cette manière :

# Proposition de liste non exhaustive de mots pour les rôles grammaticaux
# déterminants, pronoms, conjonctions et prépositions
dico = {
	# ARTICLES / DETERMINANTS
	"DET": ["le", "la", "les", "l'", "un", "une", "des", "d'",
		"du", "de", "au", "aux", "ce", "cet", "cette", "ces",
		"mon", "son", "ma", "ta", "sa", "mes", "ses",
		"notre", "votre", "leur", "nos", "vos", "leurs",
		"aucun", "aucune", "aucuns", 
		"tel", "telle", "tels", "telles",
		"tout", "toute", "tous", "toutes",
		"chaque"],
	# PRONOM
	"PRO": ["je", "tu", "il", "elle", "on", "nous", "vous", "ils", "elles",
		"me", "m'", "moi", "te", "t'", "toi",
		"se", "y", "le", "lui", "soi", "leur", "eux", "lui",
		"qui", "que", "quoi", "dont" "où"],
	# CONJONCTION
	"CONJ": ["mais", "ou", "et", "donc", "or", "ni", "car",
		"que", "quand", "comme", "si",
		"lorsque", "quoique", "puisque"],
	# PREPOSITION
	"PREP": ["à", "derrière", "malgré", "sauf",
		"selon", "avant", "devant", "sous", "avec", 
		"en", "par", "sur", "entre", "parmi", 
		"envers", "pendant", "vers", "dans", "pour", "de", 
		"près", "depuis", "sans"]
}

Le contexte syntaxique en renfort

Les différents mots extraits précédemment vont nous être utiles pour capturer les mots des autres catégories.

Nous allons fouiller le contexte des mots listés précédemment afin d'identifier des règles qui nous permettraient de trouver automatiquement les catégories grammaticales des autres mots. Pour ce faire nous allons utiliser un outil bien connu des linguistes : le concordancier.

Nltk fournit un concordancier qui est certes un peu limité mais bien suffisant pour notre étude :

t = nltk.Text(words, name="discours_sarkozy")
t.concordance("je", width=50, lines=10)

ce qui nous donne le résultat :

Displaying 10 of 21483 matches:
re avec vous ce matin . Je sais qu' il y a plus de
onsulat dans le monde . Je salue tous ces Français
 , c' est sympathique ! Je suis donc venu vous voi
France-Syrie du Sénat . Je suis également venu ave
la Syrie et la France . Je n' oublie pas Bernard B
des parents d' élèves . Je voudrais vous féliciter
État sera à vos côtés . Je trouve cela formidable 
aissera pas tomber . Et je trouve que c' est merve
nt engagés à ce point . Je veux saluer également l
nsi que les autorités . Je salue ici la présence 

L'utilisation du concordancier nous permet de lister un certain nombre de règles qui semblent généralisables :

  • le "or" en début de phrase est une conjonction alors que le "or" précédé d'un déterminant est un nom ;
  • les déterminants semblent précéder des noms ;
  • les pronoms sont en général suivis d'un verbe ou d'un autre pronom
  • les adjectifs sont en général précédés d'un verbe ou d'un nom
  • ...

Outre ces quelques règles auxquelles il est très certainement possible de trouver des contre-exemples, on peut surtout noter que les mots ne s'acoquinent pas avec n'importe qui. Chaque mot a des accointances privilégiées avec certaines catégories grammaticales (le déterminant avec le nom par exemple, ...). En d'autres termes, il semble exister une structure syntaxique propre à la langue... sa compréhension nous permettrait de faire un pas de plus dans la compréhension du langage par la machine.

Algorithme de Brill

Intuition

Prenons une phrase du corpus :

[u'Je', u'sais', u"qu'", u'il', u'y', u'a', u'plus',
 u'de', u'3 000', u'Fran\xe7ais', u'en', u'Syrie', u',', 
 u'\xe0', u'Damas', u',', u'\xe0', u'Alep', u',', u'o\xf9', 
 u'nous', u'avons', u'notre', u'plus', u'ancien', u'consulat', 
 u'dans', u'le', u'monde', u'.']

Un certain nombre des mots qui composent cette phrase sont connus car ils appartiennent aux catégories grammaticales déterminant, pronom, préposition ou conjonction. C'est le cas notamment de Je, qu', il, y, de, en, à, à, , nous, notre, dans et le. L'idée est donc d'affecter à ces mots leur étiquette la plus probable et aux autres l'étiquette ?, soit :

[('Je', 'PRO'), ('sais', '?'), ("qu'", 'PRO'), ('il', 'PRO'),
 ('y', 'PRO'),  ('a', '?'), ('plus', '?'), ('de', 'PREP'), 
 ('3 000', '?'), ('Fran\xe7ais', '?'), ('en', 'PREP'), 
 ('Syrie', '?'), (',', 'PUN'), ('\xe0', 'PREP'), ('Damas', '?'), 
 (',', 'PUN'), ('\xe0', 'PREP'), ('Alep', '?'), (',', 'PUN') , 
 ('o\xf9', 'PREP'), ('nous', 'PRO'), ('avons', '?'), 
 ('notre', 'DET'), ('plus', '?'), ('ancien', '?'), ('consulat', '?'), 
 ('dans', 'PREP'), ('le', 'DET'), ('monde', '?'), ('.', 'PUN')]

L'application de règles simples naïves telles que :

  • Un pronom est suivi d'un pronom ou d'un verbe ;
  • Un déterminant est suivi d'un adjectif ou d'un nom ;
  • Une préposition est suivie d'un nom propre, d'un pronom ou d'un déterminant ;
  • Un adjectif est suivi d'un adjectif, d'un nom ou d'une ponctuation ;
  • ...

permettent de deviner les catégories grammaticales qui peuvent s'appliquer aux mots inconnus de nos dictionnaires. Nous pourrons ensuite compléter notre dictionnaire de ces nouveaux et ainsi enrichir notre connaissance itérativement.

C'est grosso modo le concept de l'algorithme de Brill...

Fonctionnement

L'algorithme de Brill parcourt les mots du texte. Si la forme textuelle est présente dans son dictionnaire, il lui associe la catégorie grammaticale la plus probable, c-à-d celle la plus communément rencontrée pour ce mot. Lorsque la forme textuelle n'est pas présente dans le dictionnaire, il lui associe l'étiquette NOM ou NOM PROPRE si elle commence par une majuscule. Une fois cette première passe opérée, l'algorithme applique des règles de transformation des étiquettes telles que vues précédemment.

On dit que l'algorithme est guidé par l'erreur (error-driven) car il fait appel à de l'apprentissage supervisé (sélection des règles).

Pour en savoir plus, vous pouvez lire cet article écrit par Brill lui-même.

Brill dans nltk

Nltk intègre une implémentation de l'algorithme de Brill, ainsi que l'outillage nécessaire à l'entraînement d'un nouvel étiqueteur.

Voir l'article
Travaux Pratique TALN - Morphologie et contexte syntaxique
14 nov. 2011

Dans le TP précédent nous nous sommes limité à l'analyse des mots en-dehors de tout contexte. Il s'est alors agi de découper un texte en mots puis de compter ces derniers.

Dans ce second TP, nous allons nous intéresser à la morphologie des mots (leur forme textuelle). Nous explorerons notamment deux procédés de normalisation morphologique : la racinisation et la lemmatisation. Nous découvrirons ensuite que le contexte des mots, combiné à leur morphologie, peut nous apprendre bien des choses.

Je considère comme acquis le découpage en phrases et en mots (si ce n'est pas le cas, vous pouvez vous rafraîchir la mémoire ici). Nous allons dans cette partie directement travailler sur une version du corpus des discours de N. Sarkozy déjà découpée en phrases et en mots.

La fonction ci-dessous permet de charger la liste des phrases du corpus et pour chaque phrase la liste des mots qui la compose :

def load_preprocessed_corpus(finput="Discours-Sarkozy-v20111025.preproc.txt"):
	"""
	Load the content of a preprocessed file as a list of sentences which happen
	to be a list of words.
	"""
	fh = codecs.open(finput, "r", "utf-8")
	corpus = fh.read()
	fh.close()
	sents = []
	for sent in corpus.split("\n"):
		sents.append( sent.split() )
	return sents

Exemple d'utilisation :

>>> sentences = load_preprocessed_corpus("Discours-Sarkozy-v20111025.preproc.txt")
>>> "Nombre de phrases  : %d" % len(sentences)
'Nombre de phrases  : 84325'
>>> "Nombre de mots : %d" % len([w for s in sentences for w in s])
'Nombre de mots : 2017309'

Morphologie : des expressions rationnelles à l'algorithme de Porter

Nous allons avoir besoin d'un outil puissant pour exprimer par intention les formes textuelles que nous allons manipuler : les expressions rationnelles. Celles-ci permettent de décrire un langage régulier pour lequel il sera possible de générer un automate acceptant. La plupart des langages informatiques récents offrent des bibliothèques permettant de manipuler ces expressions et les automates en découlant. Python ne fait pas exception à la règle.

Les expressions rationnelles sont gérés sous Python par le module re. Il serait trop long de présenter l'utilisation des expressions rationnelles avec Python ici, et d'autres le font beaucoup mieux que moi.

France, Français, Française, Françaises...

Le nuage de mots généré précédemment contient des mots très similaires, c'est le cas notamment de français et française. Les expressions rationnelles peuvent nous permettre de les considérer ensemble :

>>> import re
>>> len(re.findall(u"\\bfrançais\\b", text))
1031
>>> len(re.findall(u"\\bfrançaise\\b", text))
1286
>>> len(re.findall(u"\\bfran[cç]ais\\w\\b", text))
2661
>>> tous_francais = set()
>>> for m in re.finditer(u"\\bfran[cç]ais\\w*\\b", text):
...     tous_francais.add( m.group(0) )
>>> tous_francais
set([u'fran\xe7aise', u'fran\xe7ais', u'fran\xe7aises'])

Généralisation : vers l'algorithme de Porter

L'exemple précédent nous montre que nous pourrions profiter d'une normalisation morphologique des mots collectés afin de rendre mieux compte de la distribution lexicale. Après tout français, française et françaises correspondent tous trois au même mot français tel qu'on le trouve dans le dictionnaire.

En effet, française et françaises sont des flexions de l'adjectif français. En linguistique, on oppose flexions et dérivations :

  • La flexion consiste à ajouter un affixe au mot afin de refléter un changement au niveau grammatical, du genre, du nombre, de la personne, etc., sans que cette modification morphologique n'altère le sens du lexème d'origine ;
  • La dérivation consiste à créer un nouveau lexème, c-à-d un nouveau mot avec un nouveau sens, par l'ajout d'un affixe.

Les règles flexionnelles en français semblent assez régulières : ajout d'un "e" pour marquer le féminin, d'un "s" pour le pluriel... Ne pourrait-on pas définir un algorithme capable de déconstruire ces flexions afin de retrouver le lexème d'origine, au masculin singulier ?

Petit exercice :

  • Tentez de produire une liste exhaustive des suffixes utilisés pour marquer le genre et le nombre en français
  • Recherchez toutes les formes textuelles qui utilisent ces suffixes

Proposition de corrigé :

all_words = set([w.lower() for w in words])
 
def evaluation_suffix(suffix):
	global raw_text
	regexp = re.compile(r"\b(\w+)%s\b"%suffix, re.I|re.U)
	map = {}
	# Collecter les mots avec le suffixe
	for m in regexp.finditer(raw_text):
		avec_suffixe = m.group(0)
		sans_suffixe = m.group(1)
		if not map.has_key(avec_suffixe):
			map[avec_suffixe] = {
				"sans_suffixe": sans_suffixe,
				"sans_suffixe_existe": False}
	# Chercher les occurrences sans suffixe
	for k in map.keys():
		sans_suffixe = map[k]["sans_suffixe"].lower()
		map[k]["sans_suffixe_existe"] = (sans_suffixe in all_words)
	# Rapport
	nb_est_suffixe = len([k for k in map.keys() if map[k]["sans_suffixe_existe"]])
	nb_est_pas_suffixe = len(map) - nb_est_suffixe
	if nb_est_suffixe > nb_est_pas_suffixe:
		print "%s est un suffixe (%d/%d)" % (suffix, nb_est_suffixe, nb_est_pas_suffixe)
	else:
		print "%s n'est PAS un suffixe (%d/%d)" % (suffix, nb_est_suffixe, nb_est_pas_suffixe)
 
suffixes = ["ous", "aux", "s", "e", "ais", "ives", "ent", "es", "ai", "ons", "ez", "t", "ait", "ions", "iez", "aient", "ant", "le", "les", "ne", "nes", "x"]
 
for suffix in suffixes:
	evaluation_suffix(suffix)

Algorithme de Porter

L'algorithme de Porter offre un cadre algorithmique pour la désuffixation des mots. Le fonctionnement de l'algorithme est décrit en de nombreux endroits sur le Web, notamment ici ou sur mon propre blog. S'il a été mis au point pour l'anglais, il est tout à fait envisageable de l'utiliser pour le français (d'ailleurs certains l'ont fait).

L'idée de l'algorithme de Porter est de déconstruire les flexions pour retrouver la forme canonique des mots : le lemme. Par exemple : retirer le suffixe "aux" de chevaux et le remplacer par "al" pour obtenir le singulier. Les règles de Porter sont déclenchées lorsque deux conditions sont réunies :

  1. le mot se termine par un suffixe particulier
  2. le radical (mot privé dudit suffixe) respecte un motif particulier, le plus généralement un certain nombre de pseudo-syllabes

Le code python ci-dessous est une proposition d'implémentation de la règle de Porter pour normaliser journaux et chevaux en respectivement journal et cheval :

def compute_m(w):
     pseudosyllabs = ""
     for c in w:
             if c in ['a', 'e', 'i', 'o', 'u', 'y']:
                     if len(pseudosyllabs)==0 or pseudosyllabs[-1]=="C":
                             pseudosyllabs += "V"
             else:
                     if len(pseudosyllabs)==0 or pseudosyllabs[-1]=="V":
                             pseudosyllabs += "C"
     return pseudosyllabs.count("VC")
 
# Règle de Porter : (m=1 && *c) aux -> al
# Le mot se termine par aux, a un radical composé d'une pseudo-syllabe et qui se termine par une consomne
def regle_porter_aux(w):
     if w.endswith("aux"):
          radical = w[:-3]
          if not radical[-1] in ['a', 'e', 'i', 'o', 'u', 'y']:
               return radical + "al"
     return w
 
>>> regle_porter_aux("journaux")
'journal'
>>> regle_porter_aux("chevaux")
'cheval'
>>> regle_porter_aux("agneaux")
'agneaux'

Petit exercice :

Voir l'article
Rencontre des contributeurs Nantais à OSM
03 nov. 2011

Les contributeurs Nantais à Open Street Map (OSM pour les intimes) se donnent rendez-vous ce soir (jeudi 3 novembre 2011) au bar le Flesselles à 19h30.

À l'ordre du jour :

  • Boire un verre (ou plusieurs) tous ensemble
  • Discuter de la création récente de l'association OSM nationale
  • Échanger autour de nos pratiques (outils, méthodes, zones couvertes, ...)
  • Réfléchir à de futures cartoparties (accessibilité notamment)
  • Rétablir la route du campus Tertre

Ces rencontres sont ouvertes à toutes les personnes curieuses de découvrir OSM, pas seulement aux contributeurs réguliers (et moins réguliers).

Voir l'article
Travaux Pratique TALN - Le lexique
24 oct. 2011

Malgré notre projet de création d'entreprise, je tenais à continuer à enseigner le TALN à l'Université. Outre l'intérêt pragmatique du chef d'entreprise qui souhaite ainsi repérer les éléments prometteurs à recruter, l'enseignement est une des meilleures manières de prendre du recul sur un domaine.

Cette année je compte mettre de côté UIMA pour me concentrer sur l'expérimentation. J'ai donc décidé de me tourner vers Python, mon langage de cœur, et les bibliothèques NLTK et scikit-learn.

Pour ce premier TP, je compte faire réfléchir les étudiants sur l'analyse lexicale : découpage d'un texte en mots, calculer une distribution sur un document, puis un corpus, filtrer les mots qui participent peu à l'expression du sens et visualiser un texte à partir de son lexique.

Constitution du corpus

Si la construction du corpus est toujours primordiale, celle-ci revêt un enjeu d'autant plus stratégique en enseignement que le corpus peut captiver ou repousser les étudiants.

J'ai un temps été tenté par les messages de condoléances laissés par les fans d'Apple à l'occasion de la mort de Steve Jobs. Neil Kodner a réalisé une superbe analyse sur le sujet. Toutefois, je préfère faire travailler mes étudiants sur le français qui a des propriétés particulières qu'on ne retrouve pas en anglais, notamment sa forte flexionnalité.

À l'occasion du lancement par Google d'un concours de visualisation de données sur la thématique des élections présidentielles 2012, je me suis une nouvelle fois tourné vers les discours politiques. Ceux-ci sont souvent chargés de symboles qui s'expriment souvent par le lexique, d'où l'intérêt de les utiliser dans ce genre d'étude. Bien sûr l'objectif n'est pas de chercher à atteindre la qualité des études menées avec brillo par Jean Véronis, mais simplement se faire la main sur quelques textes contemporains d'intérêts.

Pour la constitution du corpus, je me suis simplement tourné vers le site de l'Élysée. J'avais commencé à collecter les différents discours de notre président, mais une mise-à-jour du site (fortement inspiré du site de la Maison Blanche) m'avait coupé dans mon élan. Le point positif est que ce nouveau site met l'accent sur l'accessibilité. J'ai écrit un petit script Python qui permet d'aller récupérer tous les discours. Il est un peu simpliste et ramène donc un peu de bruit, mais suffisamment peu pour que le résultat soit exploitable :

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Scrap the speeches from M. President Sarkozy :)
 
import sys
import codecs
import urllib2
from BeautifulSoup import BeautifulSoup
 
for did in range(12300):
	sys.stdout.write("Dealing with page %d..."%did)
	try:
		# Collect the page
		uh    = urllib2.urlopen("http://www.elysee.fr/president/root/bank/print/%d.htm" % did)
		html  = uh.read()
		uh.close()
		# Check it is a discourse (discourse subcategory)
		if html.find("/president/root/core/D00018") >= 0:
			# Extract the text
			soup  = BeautifulSoup(html)
			zone  = soup.body.find("div", id="zonePrincipale")
			texts = [t.strip() for t in zone.findAll(text=True) if len(t.strip())>0]
			# Export it
			fh = codecs.open("%d.txt"%did, "w", "utf-8")
			for t in texts:
				fh.write(t + "\n\n")
			fh.close()
			sys.stdout.write("done\n")
		else:
			sys.stdout.write("ignored\n")
	except Exception:
		sys.stdout.write("error\n")

À l'heure de l'écriture de ce billet, le script permet de collecter 588 discours (ou pages identifiées comme tels) pour un total de 1943116 mots. L'export de l'extraction peut-être téléchargé ici.

Découpage en mots

J'ai déjà discuté plusieurs fois du découpage en mots sur ce blog : avec NLTK ou de manière plus théorique.

Je vois trois façons d'approcher le problème du découpage en mots :

  1. Rechercher les sous-chaînes qui constituent les mots ;
  2. Rechercher les sous-chaînes qui séparent les mots ;
  3. Identifier les frontières entre les mots et les non-mots.

La première approche est celle qui a été auparavant discutée sur ce blog. L'idée générale est qu'un mot (ou plus précisément un lexème) est une séquence continue de lettres ou de chiffres. Les choses se compliquent lorsque l'on considère les cas particuliers : les articles et pronoms contractés (l', d', j', m', ...) ; les composés lexicaux à apostrophe (aujourd'hui, ...) ; les composés lexicaux à traits d'union (arc-en-ciel, peut-être, sauve-qui-peut, ...) ; les valeurs numériques (14 000, 14,18, 30 %, ...) ; les acronymes (ASSEDIC, ASCII, ...) ; les sigles (C-4, c-à-d, i.e., ...) ou encore les unités de mesure (A/m, km/h, ...).

La seconde approche me semble biaisée par nature. Elle fonctionne suffisamment bien pour l'anglais où l'on peut envisager découper un texte en mots en coupant à l'endroit des espaces. Elle est toutefois inefficace pour le français étant donné le nombre de cas où les mots sont séparés par une chaîne vide (présence d'un apostrophe, ponctuation, ...).

La dernière approche serait celle que je mettrais en œuvre si je devais implémenter un algorithme de découpage en mot par apprentissage. L'idée serait alors de classer chaque caractère dans une des catégories : début de mot, fin de mot ou n'appartenant pas à un mot. Je ne connais pas l'état de l'art des techniques de tokenisation automatique, mais une approche par n-grammes caractères devrait assez bien fonctionner. Il faudrait toutefois étudier lequel du contexte gauche ou droit est le plus important (si l'un des deux est plus important que l'autre).

Pour l'exercice nous utiliserons le RegexpTokenizer de nltk avec l'expression régulière suivante (à améliorer éventuellement) :

import re
from nltk.tokenize.regexp import RegexpTokenizer
 
reg_words = r'''(?x)
          aujourd'hui    # exception 1
        | prud'hom\w+ # exception 2
        | \d+(,\d+)?\s*[%€$] # les valeurs
        | \d+                # les nombres
        | \w'                 # les contractions d', l', j', t', s'
        | \w+(-\w+)+    # les mots composés
        | (\d|\w)+         # les combinaisons alphanumériques
        | \w+               # les mots simples
        '''
tokenizer = RegexpTokenizer(reg_words, flags=re.UNICODE|re.IGNORECASE)

L'utilisation du RegexpTokenizer est des plus simple, il suffit d'appeler la méthode tokenize sur un texte pour obtenir en retour la liste des mots extraits dudit texte :

>>> tokenizer.tokenize(text)

[u'Hommage', u'\xe0', u'M', u'Aim\xe9', u'C\xc9SAIRE', u'ALLOCUTION', u'DE', u'M', u'LE', u'PR\xc9SIDENT', u'DE', u'LA', 
u'R\xc9PUBLIQUE', u'FRAN\xc7AISE', u'A\xe9roport', u'de', u'Fort-de-France', u'Martinique', u'Dimanche', u'20', u'Avril', u'2008', u"C'", 
u'est', u'avec', u'une', u'profonde', u'\xe9motion', u'que', u'je', u'viens', u"aujourd'hui", u'rendre', u"l'", u'hommage', u'de', u'la', 
u'Nation', u'\xe0', u'Aim\xe9', u'C\xc9SAIRE', u'qui', u'nous', u'a', u'quitt\xe9s', u'jeudi', u'dernier', u'Ma', u'place', u'ne', u'pouvait', 
u'\xeatre', u"aujourd'hui", u'que', u'sur', u'cette', u'terre', u'de', u'Martinique', u'aux', u'c\xf4t\xe9s', u'de', u'ceux', u'qui', u'sont', 
u'dans', u'la', u'peine', u'Mes', u'premi\xe8res', u'pens\xe9es', u'vont', u'naturellement', u'\xe0', u'la', u'famille', u'endeuill\xe9e', 
u'qu', u'avec', u'les', u'ministres', u'je', u'rencontrerai', u'dans', ...

Distribution des mots

L'objet du découpage en mot est, outre de se confronter à la difficulté bien réelle de la tâche, de mesurer la distribution du lexique dans le corpus des discours. Plus simplement dit, il s'agit de compter le nombre d'occurrences de chacun des mots utilisés. Par mot, j'entends ici lexie (bien sûr ^^), soit la forme textuelle qui fait référence au mot tel qu'on le trouve dans le dictionnaire.

Pour le comptage des mots, il est possible d'utiliser un simple dictionnaire en gérant l'ajout d'une clé pour chaque nouveau mot rencontré. NLTK propose toutefois une classe qui permet de s'affranchir de cette déclaration et de se concentrer uniquement sur le comptage : la classe FreqDist.

from nltk.probability import FreqDist
 
# Comptage des mots à la création
fdist = FreqDist(  tokenizer.tokenize(text)  )
 
# ... ou bien en parcourant la liste
fdist = FreqDist()
for word in  tokenizer.tokenize(text):
     fdist.inc( word.lower() )

En plus d'économiser la déclaration des mots, la classe ordonne automatiquement les mots par fréquence décroissante :

>>> fdist.items()[:30]

[(u'de', 53), (u'la', 36), (u'qui', 23), (u'et', 19), (u"l'", 18), (u'un', 17), (u'\xe0', 14), (u'des', 13),
 (u'que', 13), (u'les', 12), (u'le', 11), (u'a', 10), (u'est', 10), (u'homme', 10), (u'sa', 9),
 (u'aim\xe9', 8), (u'c\xe9saire', 8), (u'je', 8), (u"c'", 7), (u'dans', 7), (u'du', 7), (u'cet', 6),
 (u'france', 6), (u'martinique', 6), (u'nous', 6), (u'ses', 6), (u'avec', 5), (u'ce', 5), (u"d'", 5), (u'martiniquais', 5)]

Filtrage des mots non signifiants

Tous les mots d'un texte ne jouent pas le même rôle : certains participent à la construction du sens (je les appellerai signifiants), d'autres ont un rôle d'assemblage (je les appellerai outils). La catégorisation est volontairement extrêmement grossière. Toute la profondeur de l'analyse lexicale réside précisément dans le juste filtrage des mots qui n'apporte pas de sens dans le contexte de l'énonciation. Comment les sélectionner ?

Une hypothèse simple, mais qui fonctionne assez bien, est de considérer que les mots les plus communs ne participent pas à l'élaboration du sens. L'hypothèse est globalement validé si l'on observe le top 50 des mots du corpus :

>>> fdist.items()[:50]

[(u'de', 101164), (u'la', 67872), (u'le', 48749), (u'et', 42159), (u"l'", 41465), (u'les', 36784), (u'que', 33999),
 (u'\xe0', 31771), (u'des', 30118), (u'est', 30097), (u"d'", 26459), (u'en', 22888), (u'qui', 22072), (u'je', 21717),
 (u'un', 20687), (u'pas', 20162), (u'pour', 19479), (u'du', 18521), (u'il', 17603), (u'une', 17362), (u'a', 16495),
 (u'nous', 16219), (u"c'", 15088), (u'dans', 14612), (u'vous', 14175), (u'on', 13874), (u'ce', 13645), (u'ne', 13540),
 (u'qu', 12816), (u"n'", 12401), (u'au', 11463), (u'plus', 9716), (u'sur', 8973), (u'france', 8316), (u'avec', 8311), (u'y', 8047),
 (u'mais', 7662), (u'pr\xe9sident', 7540), (u"j'", 6409), (u'par', 6384), (u'se', 6252), (u'sous', 6225), (u'r\xe9publique', 6179),
 (u'sont', 5905), (u"s'", 5809), (u'aux', 5673), (u'publi\xe9', 5586), (u'class\xe9', 5576), (u'cette', 5564), (u'cela', 5555)]

Dans les faits, il semble important de conserver des mots tels que france ou république. Au final, il est donc préférable d'opérer la sélection manuellement.

Une première phase de filtrage consiste à supprimer toutes les entrées qui correspondent aux mots outils classiques (déterminants, pronoms, verbes être et avoir, ...). NLTK propose une telle liste de mots :

from nltk.corpus import stopwords
 
for sw in stopwords.words("french"):
     if fdist.has_key(sw):
          fdist.pop(sw)

Il faut ensuite la compléter en observant manuellement, parmi les mots les plus fréquents, ceux qui n'apportent pas d'information sur le contenu d'un discours politique. J'ai déposé une proposition d'anti-dictionnaire composé de 318 mots, encodés en UTF-8, dans l'espace de téléchargement du corpus.

Le top 50 des mots du corpus une fois l'anti-dictionnaire appliqué est beaucoup plus parlant :

>>> fdist.items()[:50]

[(u'france', 8316), (u'pr\xe9sident', 7540), (u'r\xe9publique', 6179), (u'monde', 4083), (u'fran\xe7ais', 3194),
 (u'europe', 3189), (u'travail', 2434), (u'ministre', 2331), (u'\xe9tat', 2175), (u'politique', 2130),
 (u'fran\xe7aise', 1812), (u'question', 1770), (u'crise', 1755), (u'avenir', 1515), (u'entreprises', 1480),
 (u'r\xe9forme', 1430), (u's\xe9curit\xe9', 1424), (u'vie', 1424), (u'besoin', 1420), (u'conseil', 1397),
 (u'international', 1319), (u'emploi', 1318), (u'recherche', 1314), (u'gouvernement', 1308), (u'\xe9conomie', 1253),
 (u'd\xe9veloppement', 1194), (u'etat', 1153), (u'palais', 1136), (u'paris', 1123), (u'droit', 1098),
 (u'histoire', 1079), (u'service', 1074), (u'\xe9conomique', 1054), (u'syst\xe8me', 1046), (u'enfants', 1006),
 (u'jeunes', 1000), (u'afrique', 985), (u'moyens', 954), (u'europ\xe9enne', 950), (u'plan', 941), (u'croissance', 925),
 (u'nationale', 903), (u'amis', 900), (u'loi', 888), (u'ministres', 885), (u'sant\xe9', 885), (u'projet', 873),
 (u'hommes', 842), (u'euros', 833), (u'culture', 829)]

Visualisation

La dernière étape du TP consiste à visualiser les données ainsi collectées. Il existe plusieurs méthodes de visualisation de ce type de données (voir notamment ce billet de Jean Véronis), j'ai choisi la plus simple de toutes : le nuage de mots.

J'utilise l'outil Wordle pour générer mes nuages, et plus particulièrement l'interface avancée qui permet d'indiquer soit même les mots retenus et leur pondération.

J'ai retenu pour le nuage les 500 mots les plus présents dans le corpus. Leur pondération est calculée à partir de leur fréquence multipliée par 10000 :

for w in fdist.keys()[:500]:
     print "%s:%d" % (w, int(fdist.freq(w)*10000))

Le résultat est visible dans la galerie publique de Wordle, ou ci-dessous :

Nuage de mots du corpus des discours publiques de N. Sarkozy en tant que président de la République (jusqu'au 24 octobre 2011).

Voir l'article
Premières semaines en tant qu'entrepreneur
02 sept. 2011

L'aventure universitaire s'est terminée avec mon contrat d'ingénieur de recherche à l'INRA Nantes-Angers. Je suis désormais entrepreneur, dans l'hypothèse où c'est un statut figé dans le temps ! C'est le début d'une toute nouvelle aventure, extrêmement excitante... C'est aussi l'occasion de légèrement modifier la ligne éditoriale de ce blog (mais pas trop quand même), à commencer par son titre !

Cela fait une quinzaine de jours que Matthieu et moi même travaillons à temps plein sur notre projet d'entreprise Dictanova. Et le temps passe vite ! Petit récapitulatif des épisodes (non diffusés) précédents...

Logo dictanova

Comme tout doctorant (j'imagine), la fin de la thèse a été l'occasion de me poser les questions existentielles classiques sur mon avenir : maître de conférence ? entreprise ? chômeur ? Et pourquoi pas créer ma propre boîte ? La balance a rapidement pesé à l'avantage de la création d'entreprise par rapport à la voie universitaire royale, comprenez : contrats précaires à l'étranger (aussi appelés stages post-doctoraux), campagne de recrutement, se faire bouler, contrats précaires, campagne de recrutement... Toutefois j'étais frileux à l'idée de me lancer seul dans l'aventure. J'avais besoin de faire ça avec quelqu'un de compétent, en qui j'ai confiance et avec qui j'ai des liens d'amitié suffisamment forts pour résister aux tempêtes à venir : Matthieu.

L'idée ancrée dans nos têtes, il a fallut trouver un catalyseur pour se lancer. Pour nous ça a été le programme des entrepreneuriales. Il s'agit d'une initiative fantastique pour découvrir la création d'entreprise, et y prendre goût. C'est l'occasion avant tout de rencontrer des gens : des entrepreneurs passionnés (notre parrain notamment), des structures prêtes à vous accompagner, ... et des clients potentiels !

Une dizaine de rendez-vous de prospection, un business plan et accessoirement une soutenance de thèse plus tard, l'aventure était réellement lancée. Nous avons alors proposé à Jérôme de nous rejoindre comme troisième associé fondateur afin de compléter le panel des compétences techniques de l'équipe.

S'ensuit un parcours jalonné :

La suite va s'écrire dans les prochaines semaines. Le passage en comité d'engagement tout d'abord, afin de convaincre Atlanpole de nous incuber. L'hébergement au sein même du LINA ensuite qui nous permettra de rester au contact de nos collègues chercheurs.

Bien des évènements à venir que je compte faire partager au travers ce blog... d'ici là, retour au boulot : il y a du pain sur la planche !

Voir l'article
Cartopartie OSM sur Nantes le samedi 23 juillet 2011
19 juil. 2011

Les contributeurs nantais à OpenStreetMap (OSM) organisent une cartopartie ce samedi 23 juillet. La journée est placée sous le signe de la circulation douce : l'objectif est de corriger et compléter les infrastructures dédiées à la circulation douce sur l'agglomération nantaise (pistes cyclables, bornes, ...).

Le rendez-vous est donné à 10h30 au Café Flesselles.

Au programme :

  • Présentation de l'état des lieux et identification des zones prioritaires pour la cartopartie avec une intervention de Benoît Grunberg de GéoVélo ;
  • Formation des équipes et affectation des zones à cartographier ;
  • Fusion des données collectées.

L’événement est ouvert à tous, que vous soyez un novice qui n'a même jamais entendu parlé d'OpenStreetMap ou bien un contributeur confirmé. L'objectif de cette rencontre est de faire avancer le projet OSM dans la convivialité.

Pensez à apporter votre matériel de cartographe (bloc-notes, GPS, appareil photo...), et si possible venez en vélo puisque la cartopartie devrait principalement s'effectuer par ce mode de transport.

Voir l'article
Map/Reduce dans MongoDB
11 juin 2011

L'un des intérêts de la mouvance NoSQL est d'intégrer le paradigme du Map/Reduce qui consiste à partitionner les données afin de les traiter en parallèle. MongoDB ne fait pas exception et intègre directement ces concepts.

J'explique dans ce billet comment compter le nombre de documents associés à chaque valeur d'un attribut.

Map/Reduce ?

Le principe du paradigme Map/Reduce (oui j'ai choisi de considérer qu'il s'agissait d'un paradigme... carrément) Map/Reduce est de diviser les données à traiter en partitions indépendantes, traiter ces partitions en parallèle et finalement combiner les résultats des traitements parallèles.

Le Map consiste en l'étape de découpage puis de distribution des différentes partitions de données constituées. La mise en œuvre se réalise habituellement à l'échelle d'une grappe de serveurs. Le Map est alors réalisé par un nœud qui distribue les données à d'autres nœuds. Chaque nœud en réception se charge alors du traitement sur les données reçues.

Le Reduce est l'opération inverse du Map qui consiste à récolter tous les résultats calculés en parallèle et les fusionner (reduce) en un seul résultat global. Chaque partition de données distribuée se structure comme un couple clée/valeur. La clée est utilisée lors de la fusion pour regrouper les valeurs qui vont ensemble. Toutes les valeurs associées à une clée sont donc réunies à la fin du Reduce.

Le schéma ci-dessous résume le principe général :

Principe général du Map/Reduce

... et dans MongoDB ?

Le principe de Map/Reduce est directement implémenté dans l'API de MongoDB, soit par les divers pilotes, soit par le shell à travers la méthode mapReduce des collections. La méthode mapReduce prend en paramètre une fonction map, une fonction reduce et un certain nombre d'autres paramètres optionnels passés comme un objet.

La fonction Map

La fonction map ne prend pas de paramètre mais elle accède directement à l'entrée de la collection considérée par le biais de this. Le rôle principal de cette méthode est d'émettre, à l'aide de la fonction emit, un couple clé/objet. Par exemple, la fonction ci-dessous retourne un tel couple avec pour clé la valeur de l'attribut croisclus de l'entrée et pour objet une simple paire clé/valeur : instances/1 :

m = function () {
  emit(this.croisclus, {instances: 1});
}

La fonction Reduce

La fonction reduce prend en paramètres une clé et une collection d'objets associés à cette clé tels que générés par la fonction map. Elle produit en retour un objet de même structure que ceux passés en paramètre. Étant donné que la fonction reduce peut-être appelée de manière itérative avec en paramètre des objets qu'elle a elle même générée, il est important qu'elle soit idempotente. En d'autres termes, les égalités suivantes doivent être respectées :

  • reduce(k, A ,B) == reduce(k, B, A)
  • reduce(k, A ,B) == reduce(k, reduce(k, A,B))

Par exemple, la fonction ci-dessous consomme les couples générés par la fonction map et additionne les valeurs de l'attribut instances qui sont associées à une même clée k. En d'autre terme elle fusionne tous les objets de même clé k en un seul objet en additionnant leurs valeurs instances :

r = function (k, vals) {
  var result = {instances: 0};
  vals.forEach( function(value) {
      result.instances += value.instances; 
    }
  );
  return result;
}

La fonction mapReduce

Une fois ces deux fonctions écrites, il ne reste plus qu'à lancer l'opération de Map/Reduce sur la collection ou éventuellement sur un sous-ensemble de la collection définie par une requête. Depuis la version 1.8 de MongoDB, il est nécessaire de préciser le nom de la collection où seront stockés les résultats du processus à l'aide d'un attribut out dans le paramètre optionnel :

res = db.nano.mapReduce(m, r, {out: 'test1'})
{
	"result" : "test1",
	"timeMillis" : 39517,
	"counts" : {
		"input" : 167340,
		"emit" : 167340,
		"output" : 2332
	},
	"ok" : 1,
}

Les résultats du processus sont alors disponibles dans ladite collection :

db.test1.find().limit(10)
{ "_id" : "C10M10", "value" : { "instances" : 2 } }
{ "_id" : "C10M11", "value" : { "instances" : 3 } }
{ "_id" : "C10M12", "value" : { "instances" : 5 } }
{ "_id" : "C10M13", "value" : { "instances" : 10 } }
{ "_id" : "C10M15", "value" : { "instances" : 1 } }
{ "_id" : "C10M16", "value" : { "instances" : 10 } }
{ "_id" : "C10M17", "value" : { "instances" : 21 } }
{ "_id" : "C10M18", "value" : { "instances" : 19 } }
{ "_id" : "C10M19", "value" : { "instances" : 674 } }
{ "_id" : "C10M20", "value" : { "instances" : 2 } }
Voir l'article