CurvyBot

De Wikipast
Sauter à la navigation Sauter à la recherche
Langue Français English

Résumés des fonctionnalités

L'objectif de ce bot est de créer pour chaque biographie un graphe avec en abscisse le temps (en années) et en ordonnée le nombre d'articles par année qui traitent du personnage de la biographie. Les événements mentionnés dans la biographie sont clairement indiqués. Le graphe doit donner une idée de la popularité médiatique du personnage au fil du temps, en fonction des événements marquants le concernant.

Description technique

Le bot se base sur les biographies existantes. La page de chaque biographie est analysée pour en extraire le nom du personnage, des mots remarquables qui seront utilisés pour l'évaluation de la performance et les événements biographiques qui seront affichés sur le graphe.

Le nom du personnage est utilisé pour effectuer une requête à la base de données Impresso, qui retourne pour chaque année le nombre d'article comprenant les mots cherchés. Ces données permettent directement de créer un graphe relatif. Chaque point indique le ratio normalisé entre le nombre d'articles total sur l'année et le nombre d'articles concernant les mots-clef. En effet, le nombre d'article total varie fortement selon les années, et un compte absolu ne serait pas représentatif de l'évolution.

Évaluation des performances

Fiabilité

L'usage de mots remarquables permet de générer plusieurs courbes en effectuant une requête plus précise. La comparaison de ces courbes permet d'évaluer leur fiabilité. On effectue notamment une recherche avec le nom complet du personnage, une recherche avec uniquement son nom de famille, et des recherches avec des mots remarquables. Plusieurs techniques d'extraction de mots remarquables sont proposées:

  • Extraction des mots à occurrences multiples dans les pages.
  • Extraction des fonctions générées par le SPARQLBot dans les mentions.

Initialement, 3 courbes sont calculées.

  • La courbe de confiance moyenne est calculée en recherchant les articles où le nom complet du personnage apparaît. C'est la courbe de référence.
  • La courbe de confiance basse est calculée en recherchant les articles où seul le nom de famille du personnage apparaît. On s'attend à trouver des pics parasites dans cette courbe. Son but est de représenter au mieux l'amplitude des courbes de confiances plus élevées en minimisant le nombre de faux négatifs.
  • La courbe de confiance haute est calculée en recherchant les articles où le nom complet ainsi qu'un mot remarquable apparaissent. On s'attend à ce que certains pics avérés manquent à cette courbe. Son but est de confirmer la présence des pics dans les courbes de confiance plus basses en minimisant le nombre de faux positifs. Cette courbe est la somme des résultats pour le nom complet + un mot remarquable, et son échelle n'est donc pas la même que les deux autres courbes, car certains articles sont comptés plusieurs fois.

Généralement, la correspondance entre la courbe de confiance élevée et la courbe de confiance moyenne est excellente. La courbe de confiance basse est souvent visiblement altérée par des homonymes.

Certains cas spéciaux donnent de mauvais résultats car le nom sélectionné par l'algorithme n'est pas pertinent pour les recherches, comme Otto von Bismarck ou Nicolas II. Ces cas ne sont malheureusement pas rares.

Ce bot est partiellement dépendant du SPARQLBot, qui lui permet d'affiner la sélection de mots remarquables. Toutefois, la présence ou non de mention ne semble pas affecter significativement la performance du bot.

Vitesse

En moyenne, le bot nécessite entre 5 et 10 secondes afin de créer un histogramme et de le téléverser sur wikipast. Un des plus gros problèmes de performance est celui du réseau qui provoque des erreurs après un trop gros nombre de connexions, le transfert des images demandant beaucoup de ressources.

Améliorations possibles

Les critères actuels de recherche occasionnent quelques difficultés. On citera

  • Le nom de famille est aussi un prénom courant (e.g. Louise Michel)
  • Le personnage est évoqué selon un titre accompagné de son nom de famille (e.g. général von Bismarck)
  • Le nom de famille est utilisé pour nommer une invention ou une marque (e.g. Ferrari)
  • Le nom de famille a un homographe (e.g. Breton, Lumière)

Le critère de définition d'un mot remarquable pourrait être revu. Actuellement, il s'agit juste d'une borne sur son nombre d'apparitions dans la biographie. Pour permettre une meilleure comparaison entre diverses personnalités il serait peut-être plus juste de choisir un nombre fixe de mots parmi les plus fréquents dans la biographie.

Le critère de recherche par le nom est donc parfois une borne peu, voir pas du tout, précise. On soulignera toutefois qu'elle prouve son efficacité dans une majorité de cas. On pourrait imaginer un premier affinage du critère de confiance bas en regardant, par exemple, si le nom se trouve dans un dictionnaire.

Certains personnages sont très peu mentionnés dans les médias et le graphe pourrait être reconstitué à partir des évènements wikipast (e.g. Yuri Gagarin)

Le format des pages est problématique. On citera

  • Le personnage n'a pas de nom de famille (e.g. Nicolas II)

De plus, le bot utilise considère le nom de famille comme étant tout les mots qui suivent le prénom. Il considèrera donc les autres prénoms comme une partie du nom de famille (e.g. Juan Manuel Fangio sera recherché par Manuel Fangio)

Exemple de résultats

Code

Nous utilisons les imports et constantes suivantes

import requests
import matplotlib.pyplot as pp
import numpy as np
from urllib.request import urlopen as urlopen
from bs4 import BeautifulSoup
from matplotlib.pyplot import figure
from matplotlib.dates import (YEARLY, DateFormatter, rrulewrapper, RRuleLocator, drange)
from datetime import datetime as dt
import nbimporter
from in_progress import *
from contextlib import closing
import time

FIRST_EVENT_SHIFT = 20 #if no birthday is found in wikipast's events then the originating event for impresso is the first one FIRST_EVENT_SHIFT years earlier
PAUSE_TIME = 180 #on timeout wait PAUSE_TIME seconds
GENERATED_FILENAME = 'CurvyBot_output.png'

user = #Bot credentials
passw = #Bot credentials
baseurl='http://wikipast.epfl.ch/wikipast/'
impresso_token = #Impresso connection token

payload={'action':'query','format':'json','utf8':'','meta':'tokens','type':'login'}
r1=requests.post(baseurl + 'api.php', data=payload)
login_token=r1.json()['query']['tokens']['logintoken']
payload={'action':'login','format':'json','utf8':'','lgname':user,'lgpassword':passw,'lgtoken':login_token}
r2=requests.post(baseurl + 'api.php', data=payload, cookies=r1.cookies)
 

Une boucle principale itère sur toute les biographies

with closing(urlopen("http://wikipast.epfl.ch/wikipast/index.php/Biographies")) as html_biographies:
    soup = BeautifulSoup(html_biographies, 'html.parser')
    
    hrefs = []
    for table in soup.findAll('table'):
        for a in table.findAll('a'):
            if a.has_attr('href'):
                hrefs.append('http://wikipast.epfl.ch' + a.get('href'))

"""for each biography creates a new section with an histogram 
if the section doesn't exist and reuploads the histogram otherwise"""
times = {}
for i, bio in enumerate(hrefs):
    print(bio)
    timeout = True
    while timeout:
        try:
            start = dt.now()
            end = start
            bns = bio_needs_section(bio)
            fname, title = None, None
            if(bns):
                fname, title = create_histogram(bio)
                end = dt.now()
            add_histogram(fname, title, bns)
            timeout = False
            print('graph realisation and insertion took', end - start)
            times[bio] = end - start
        except TimeoutError:
            print("Timeout")
            time.sleep(PAUSE_TIME)

print(times)
 

Pour chaque biographie un graphe est crée et mis sur la biographie wikipast. La création du graphe est faire avec le code suivant

def generate_plot(event_dates, birthday, bio):
    pp.figure(figsize=(8.0,4.0),dpi=100)
    plots = ask_dates(birthday, bio)
    labels = ['high', 'medium', 'low']
    events = pp.plot_date(event_dates, np.zeros(len(event_dates)), fmt='ro', label='events')
    for i, p in enumerate(plots):
        if i < len(labels):
            pp.plot_date(p[0], p[1], fmt='-', label=labels[i])
        else:
            pp.plot_date(p[0], p[1], fmt='-')
    
    ax = pp.gca()
    ax.set_xlabel('années')
    ax.set_ylabel('ratio [%]')
    pp.legend()
    pp.savefig(GENERATED_FILENAME, facecolor='w', edgecolor='k',
        orientation='portrait', format='PNG')
    pp.show()
    return GENERATED_FILENAME

def create_histogram(bio):
    event_dates, birthday, title = wikipast_events(bio)
    fname = generate_plot(event_dates, birthday, bio)
    return fname, title
 

Afin de récupèrer les différentes valeurs à dessiner nous utilisons

def ask_dates(birthday, bio):
    dict_list = bioToDictionnaries(bio, birthday, impresso_token)
    plots = [dictionnary_to_plot(d) for d in dict_list]
    return plots

def wikipast_events(bio):
    event_dates = []
    birthday = None
    title = ""
    with closing(urlopen(bio)) as html_character:
        character = BeautifulSoup(html_character, 'html.parser')
        title = character.find("h1").find(text=True)
        for megaEvent in character.find_all(["h2", "ul"]):
            if megaEvent.name == "h2" and megaEvent.text != "Biographie":
                break
            elif megaEvent.name == "ul":
                for event in megaEvent.findAll('li'):
                    event_date = event.find('a').get('title')
                    formatted = format_date(event_date)
                    if formatted:
                        event_dates.append(formatted)
                        for text in event.findAll(text=True):
                            if 'naissance' in text.lower() and not birthday:
                                birthday = formatted
    if(not birthday):
        if event_dates:
            first_event = event_dates[0]
            birthday = dt(first_event.year - FIRST_EVENT_SHIFT, first_event.month, first_event.day)
        else:
            birthday = dt.min
        
    return event_dates, birthday, title
 

Afin de formatter les dates de wikipast et de convertir les résultats d'Impresso vers une version facilement dessinable nous utilisons les fonctions utilitaires suivantes

def format_date(date):
    date_parsed = date.split(' ')[0].split('.')
    if(not date_parsed[0].isdigit()):
        return None
    completion_date = ['01','01']
    date_corrected = [date_parsed[0]]
    for i, parsed in enumerate(date_parsed[1:]):
        if parsed.isdigit():
            date_corrected.append(parsed)
        else:
            date_corrected.append(completion_date[i-1])
    date_corrected = date_corrected + completion_date[len(date_parsed)-1:2]
    
    formatted = None
    try:
        formatted = dt(int(date_corrected[0]), int(date_corrected[1]), int(date_corrected[2]))
    except ValueError as err:
        print("DATE FORMAT ERROR")
                       
    return formatted
                       

def dictionnary_to_plot(d):
    d = d.items()
    d = sorted(d, key=lambda x: x[0])
    keys_dt = [dt(y[0],1,1) for y in d]
    vals = [r[1] for r in d]
    return (keys_dt, vals)
 

Le téléversement des images sur wikipast et la création de la section "Histogramme" se fait avec ce code

#inspired by InferenceBot
def get_edit_token():
    params3='?format=json&action=query&meta=tokens&continue='
    r3=requests.get(baseurl + 'api.php' + params3, cookies=r2.cookies)
    edit_token=r3.json()['query']['tokens']['csrftoken']
    edit_cookie=r2.cookies.copy()
    edit_cookie.update(r3.cookies)
    return (edit_token, edit_cookie)

#inspired by InferenceBot
def upload_file(fname, title):
    (edit_token, edit_cookie) = get_edit_token()
    with open(fname, 'rb') as pic:
        payload = {'action': 'upload', 'filename': 'Histogramme ' + title, 'token': edit_token, 'ignorewarnings': 1}
        files = {'file': pic.read()}
        r4 = requests.post(baseurl + 'api.php', data=payload, files=files, cookies=edit_cookie)
    return 'Histogramme ' + title + ".png"

def create_content(fname):
    content ='\n==Histogramme==\n'
    content+='[[Fichier:'+fname+'|frame|left|'
    content+='Histogramme de l\'influence médiatique du personnage. '
    content+='Events indique les événements présents dans la biographie. '
    content+='Les 3 courbes décrivent la variation du nombre d\'occurences du personnage dans les journaux année après année, '
    content+='avec une sélection respectivement laxiste (low), modérée (medium) et sévère (high) des articles traitant probablement du personnage. '
    content+='Pour plus d\'informations, consultez [[CurvyBot]].'
    content+=']]\n'
    return content

"""Inserts a new section named Histogram at the end of the page named title and uploads the histogram"""
def add_histogram(fname, title, create_section=True):
    if(create_section):
        wiki_fname = upload_file(fname, title)
        content = create_content(wiki_fname)
        (edit_token, edit_cookie) = get_edit_token()
        payload={'action':'edit',
                 'assert':'user',
                 'format':'json',
                 'utf8':'',
                 'appendtext':content,
                 'summary':'Histogram insertion',
                 'title':title,
                 'token':edit_token}
        r4=requests.post(baseurl+'api.php',data=payload,cookies=edit_cookie)
        
"""Used to check if a given biography already contains a header with the histogram title"""
def bio_needs_section(bio):
    with closing(urlopen(bio)) as html_character:
        character = BeautifulSoup(html_character, 'html.parser')
        for header in character.findAll("h2"):
            if 'Histogramme' in header.text:
                return False
    return True
 

Le code qui sut sert à faire les requêtes à Impresso et calculer les ratios. On y utilise les imports suivant

import requests
from urllib.parse import urlencode
from collections import OrderedDict
import os
from urllib.request import urlopen as urlopen
from bs4 import BeautifulSoup
import re
from contextlib import closing
 

La fonction principale de ce module et utilisée pour récupèrer les trois courbes est

# Return [highConfidence, mediumConfidence, lowConfidence] in the right format
def bioToDictionnaries(bio, birthday, impresso_token):
    
    NORMALISATION_FACTOR = 100
    
    totalArticlesPerYear = articlePerYear(impresso_token,[])
    
    completeName = getName(bio)
    familyName = completeName.split(" ")
    if len(familyName)>1:
        familyName = " ".join(familyName[1:])
    else:
        familyName = ""
    print(familyName)
    
    completeNameArticles = articlePerYear(impresso_token,[completeName])
    familyNameArticles = articlePerYear(impresso_token,[familyName])
    
    mediumConfidenceData = yearsFormatter(completeNameArticles, totalArticlesPerYear, birthday.year, NORMALISATION_FACTOR)
    lowConfidenceData = yearsFormatter(familyNameArticles, totalArticlesPerYear, birthday.year, NORMALISATION_FACTOR)
    
    ###
    
    remarquableWords = [k for k in occurenceWords(bio, completeName)]
    for word in mentionsWords(bio):
        if word not in remarquableWords:
            remarquableWords.append(word)
    
    #Crée un seul dictionnaire avec la moyenne des ratio de toutes les recherches à haute confiance.
    highConfidenceData = {}
    numberOfRemarquableWords = len(remarquableWords)
    
    for word in remarquableWords:
        sampleArticles = articlePerYear(impresso_token,[completeName,word])
        sampleHighConfidenceData = yearsFormatter(sampleArticles, totalArticlesPerYear, birthday.year, NORMALISATION_FACTOR)
        
        for year in sampleHighConfidenceData:
            if year in highConfidenceData:
                highConfidenceData[year] += sampleHighConfidenceData[year]
            else:
                highConfidenceData[year] = sampleHighConfidenceData[year]
    
    #for year in highConfidenceData:
        #highConfidenceData[year] = highConfidenceData[year]/numberOfRemarquableWords
    
    ###
    
    return [highConfidenceData, mediumConfidenceData, lowConfidenceData]
 

On y retrouve le NORMALISATION_FACTOR qui sert à rendre les valeurs plus lisibles et à les convertir en pourcentages. On consulte les mentions de la biographie avec

def mentionsWords(bio):
    
    with closing(urlopen(bio)) as html_character:
        character = BeautifulSoup(html_character, 'html.parser')
        
        mentionsFlag = False
        
        result = []
            
        for megaEvent in character.find_all(["h2", "ul"]):
            if megaEvent.name == "h2" and megaEvent.text == "Mentions":
                print(megaEvent)
                print("ACTIVATE SEARCHING")
                mentionsFlag = True

            elif megaEvent.name == "ul" and mentionsFlag:
                fonction = megaEvent.text
                if "Mention" in fonction:
                    fonction = fonction.split("en tant que")
                    fonction = fonction[1]
                    fonction = fonction.split(", dans")
                    fonction = fonction[0]
                    if fonction not in result:
                        result.append(fonction)
        
    return result
 

Les mots remarquables sont récupèrés de la manière suivante

# Code qui tente d'extraire des "mots remarquables" des biographies pour affiner les recherches

# Le but sera d'effectuer des recherches avec le nom de la personne + un de ces mots.

# Problème: certaines biographies n'ont aucun mots remarquables avec ce critère -> Il faudra affiner.
# pistes: Extraire la profession/qualificatif de la personne. Extraire des oeuvres de lui.

def occurenceWords(bio, name):
    
    MIN_INTERESTING_LENGTH = 3
    TRASH_WORDS = ['contre','entre','avec','pour','dans','leur','vous','des','par','une','sur','est','son','ses','que']
    
    with closing(urlopen(bio)) as html_character:
        character = BeautifulSoup(html_character, 'html.parser')
        
        wordFrequency = {}
        
        #Creation d'un dictionnaire avec tous les mots et leurs nombres d'apparitions pour chaque bio     
        for megaEvent in character.find_all(["h2", "ul"]): # Possibilité d'utiliser des listes !
            if megaEvent.name == "h2" and megaEvent.text != "Biographie":
                print(megaEvent)
                print(megaEvent.text)
                print("BREAKING")
                break

            elif megaEvent.name == "ul":
                for event in megaEvent.findAll('li'):
                    for expression in event.findAll(text=True):
                        for quasiWord in expression.split(' '):
                            for word in quasiWord.split("""'"""):
                                word = word.lower()
                                if(re.search('^[a-zA-Zàâéèêîûô]*$',word) and len(word) >= MIN_INTERESTING_LENGTH):
                                    if word in wordFrequency:
                                        wordFrequency[word] += 1
                                    else:
                                        wordFrequency[word] = 1
        
        
        #Traitement du dictionnaire
        TOLERANCE = 2
        trash = []
        for word in wordFrequency:
            #Ne conserve pas le nom dans les mots remarquables
            if word in name.lower():
                trash.append(word)
            
            #Ne conserve pas les mots qui n'apparaissent qu'une seule fois dans les mots remarquables
            if wordFrequency[word] <= TOLERANCE:
                trash.append(word)
                
            #Elimine des mots communs
            if word in TRASH_WORDS:
                trash.append(word)
        
        for word in trash:
            if word in wordFrequency:
                del wordFrequency[word]
    
    return wordFrequency
 

Le nom de la personne étant une donnée importante on le récupère à travers

#Donne le nom de la personne sujet de la biographie
def getName(bio):
    with closing(urlopen(bio)) as html_character:
        character = BeautifulSoup(html_character, 'html.parser')
        
        # Trouve le nom de la personne dont on traite la biographie
        name = character.find('title')
        name = str(name)
        name = name.split('<title>')
        name = name[1]
        name = name.split(" — Wikipast</title>")
        name = name[0]
        
        return name
 

Finalement, la fonction chargée de faire les requêtes à Impresso

#Retourne le nombre d'articles total par année pour les mots clefs données
#Appeler la fonction avec une liste de mots-clef vide retourne le nombre d'article par année.
def articlePerYear(impresso_token, listOfKeyWords):
    header = {'Authorization': 'Bearer ' + impresso_token}

    # Cette partie de la requête ne change jamais, elle groupe les résultats par article
    url_base = "https://impresso-project.ch/api/search?group_by=articles"
    
    query = url_base
    
    for i, keyWord in enumerate(listOfKeyWords):
        query += "&filters["+str(i)+"][type]=string&filters["+str(i)+"][q]="+keyWord
    
    query += "&facets=year"
    
    # Fait la requête avec les bons headers
    res = requests.get(query, headers=header)

    # Retourne le json de la requête
    result = res.json()
    
    if 'facets' in result['info']:
         return result['info']['facets']['year']['buckets']
    else:
        return {}