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).