Projet 0: OPSDB

Ce premier projet avait pour but de créer une base de donnée reprenant le contenu RP du forum à l'aide de de bots. Je vais présenter dans ce notebook quelques résultats intéressants issus des données récoltées. Les données utilisées tout au long de cette démonstration représentent l'état du forum au 26 janvier 2019.

Commençons par se connecter à la base de donnée.

In [1]:
%load_ext sql
In [2]:
%sql sqlite:///db/ops.db
Out[2]:
'Connected: @db/ops.db'

Aperçu

Répartition des factions

Dans un premier temps, faisons quelques observations générales sur les membres et les rps du forum.

In [3]:
%matplotlib inline
import pandas as pnd
import matplotlib.pyplot as plt
import numpy as np
import nltk
nltk.download('punkt')
from models import db_connect
from sqlalchemy.orm import sessionmaker
from nltk.tokenize import word_tokenize
conn = db_connect().connect()
In [63]:
df = pnd.read_csv('data/faction_repartition.csv', header=0)
df['Color'] = df['Color'].str.pad(width=7, side='left', fillchar='#')
In [5]:
# Create a pie chart
plt.pie(df['characters'],labels=df['Faction'], shadow=False,
    colors=df['Color'], explode=(0, 0, 0.3, 0, 0, 0, 0, 0),
    startangle=90, autopct='%1.1f%%',
    )
# View the plot drop above
plt.axis('equal')

# View the plot
plt.tight_layout()
plt.show()

Sans grande surprise, les pirates et les hors-la-loi semblent être parmis les plus nombreux. Mettons cette information sous forme de fromage pour pouvoir mieux observer la répartition proportionnelle de chaque faction parmi les personnages-joueurs.

Intéressons-nous ensuite à quelques mesures générales concernant le RP sur OPS. Commençons par le nombre de rps et de posts.

In [6]:
rps = pnd.read_sql('SELECT * FROM rps', conn)
posts = pnd.read_sql('SELECT * FROM posts', conn)
rps = rps.set_index('r_id')
rps.count()
Out[6]:
title          1888
chrono         1888
status         1888
url            1888
location_id    1888
dtype: int64
In [7]:
print(posts.count())
ext_dates = posts.agg({'date':['min','max']})
import dateparser
min_d = dateparser.parse(ext_dates['date']['min'])
max_d = dateparser.parse(ext_dates['date']['max'])
delta =  max_d - min_d
print('days since the first post: {}'.format(delta.days))
p_id     23679
text     23679
url      23679
date     23679
rp_id    23679
dtype: int64
days since the first post: 3009

On constate qu'à la date du 26/01/2019, OPS (dans sa version actuelle) est actif depuis 3009 jours, ce qui équivaut environ à 8,24 années. Sur ce laps de temps, les membres ont créés 1888 rps dans lesquels ils ont publiés 23 679 posts. Ces nombres ont été calculés en ne tenant compte que des rps actuellement non archivés.

Si on fait rapidement le calcul, ceci nous donne 7,9 posts par jours en moyenne, soit environ 236 posts en moyenne par mois.

Nombre de posts dans le temps

Pour mieux se rendre compte des implications de ces nombres colossaux, observons la répartitions des posts dans le temps. Pour des raisons de lisibilités, calculons le nombre de posts écrits par quadrimestre, pour chaque année.

In [8]:
posts = posts.sort_values('date')
posts = posts.set_index('date')
posts.index = pnd.to_datetime(posts.index)
posts['P'] = posts['rp_id'].apply(lambda x: 1 if rps.chrono[x] == 'P' else 0)
posts['FB'] = posts['rp_id'].apply(lambda x: 1 if rps.chrono[x] == 'FB' else 0)  
posts['Total'] = posts.eval('P + FB')
posts['A'] = posts.index.year
posts['M'] = posts.index.month
posts['B'] = posts.index.date
#posts.head()
Out[8]:
p_id text url rp_id P FB Total
date
2011-09-05 21:31:00 115 La nouvelle année à Luvneel \nU\nne nouvelle ... http://www.op-seken.com/t60-la-nouvelle-annee-... 60 1 0 1
2011-09-08 22:50:00 197 Journal de bord\n1er Septembre\nJe viens de pr... http://www.op-seken.com/t98-un-voyage-incroyab... 98 1 0 1
2011-09-19 14:41:00 435 En cette belle matinée d’hiver, je fais le ple... http://www.op-seken.com/t178-mission-gars-du-g... 178 1 0 1
2011-09-22 13:51:00 492 C\nela faisait déjà un bon moment que Mori mar... http://www.op-seken.com/t194-mission-lecon-de-... 194 1 0 1
2011-09-24 21:25:00 590 Chimères...\nPourtant pas une illusion !\nTroi... http://www.op-seken.com/t225-chimeres-pourtant... 225 1 0 1
In [62]:
data = pnd.DataFrame({'P':posts.P, 'FB':posts.FB, 'Total':posts.Total})
with plt.style.context('seaborn'):
    plot = data.resample('Q').sum().plot(figsize=(18, 10), style=['-', '--', ':'])
    plot.set_ylabel('number of posts')
    plot.set_title('Nombre de posts par quadrimestre')
    fig = plot.get_figure()
    fig.savefig("img/post_chrono.png")
    plot

On observe sur le graphique temporel que le nombre total de posts (la ligne rouge en pointillés) est en forme de "dents de scie" avec des hauts et des bas. Les pics se trouvent en général à cheval entre deux années tandis que les creux se trouvent en général en milieu d'année, à l'exception de l'année 2014 où on a un pic de posts en milieu d'année.

On peut facilement comprendre la nature de ces pics d'activité lorsqu'on prête attentions aux courbes pour les présents et flashbacks. Les régions à fortes activités sont en générales des régions où le nombre de posts au présent excède le nombre de posts en flashback, ce qui est caractéristique du comportement des membres durant les events.

A la lumière de cette constatation et en fouilant un peu dans les sujets crées de l'époque, on comprend que la forme de la courbe début-milieu 2014 correspond en fait au premier event de Mars qui, bien qu'ayant débuté en octobre 2013, s'est étalé jusqu'en avril 2014 et a par la suite débouché sur plusieures animations.

Une autre observation intéressante à noter est que 2014 marque clairement un tournant sur OPS en terme de volume. La quantité de posts écrits par quadrimestre à partir de ce moment là n'a plus rien à voir avec ce qui se faisait auparavant. On passe de moins de 200 posts par quadri en période normale, avec pic de quasi 800 pour le premier event, à plus de 600 posts par quadrimestre en période creuse par la suite. C'est un fait très intéressant quand on sait qu'un certain nombre de règles de fonctionnement datent de la période pré-2014 et ont donc été pensées dans un contexte qui est aujourd'hui complètement dépassé (coucou la nota).

Passé, présent, voyage dans le temps

Reprenons un instant les courbes des posts par quadrimestre mais concentrons nous cette fois-ci sur les courbes vertes et bleues (ligne continue et la ligne en tirets, pour les daltoniens) qui représentent respectivement les posts dans les rp au présent et en flashback. Si on regarde exclusivement la période post-2014, on observe une tendance intéressante :

  • il y a davantage de présents que de FB en général
  • cette différence est accrue juste avant, pendant et juste après l'event
  • Elle est faible voire inexistante en temps normal

Du coup, que s'est-il passé au premier quadrimestre de 2018 pour que le nombre de posts FB dépasse le nombre de posts au présent ? Let's find out !

In [11]:
rps['FB'] = rps['chrono'].apply(lambda x: 1 if x == 'FB' else 0)
rps['P'] = rps['chrono'].apply(lambda x: 1 if x == 'P' else 0)
rps['chrono_total'] = rps.eval('FB + P')
data_c = pnd.DataFrame({'P':rps.P, 'FB':rps.FB, 'Total':rps.chrono_total})
In [12]:
with plt.style.context('seaborn'):
    plot = data_c.sum().plot.bar(rot=0)
    fig = plot.get_figure()
    fig.savefig('img/hist_global_FB_P.png')
    

Sans surprises, il y a actuellement plus de rp au présent que de flashbacks. Voyons maintenant comment ces derniers se répartissent parmis les personnages-joueurs actuels.

In [13]:
chrono_ratio = pnd.read_csv('data/per_member_fb_p_ratio.csv', header=0)
In [14]:
data_cn = chrono_ratio.set_index('name')
with plt.style.context('seaborn'):
    plot = data_cn['ratio'].plot(figsize=(8, 15), kind='barh')
    fig = plot.get_figure()
    fig.savefig('img/hist_FB_P.png')

J'ai baptisé ce graphique : "Hall of Fame des spammeurs". Il est intéressant parce qu'il montre le ratio FB - présent pour chaque personnage, classés de bas vers le haut(donc plus le perso est en bas, plus il a participé à un nombre élevé de FB par rapport à son nombre de présents). A noter que ceux dont le pseudo n'apparait sont les personnages qui n'ont jamais réalisé de FB ou de présent (c'est le cas de 'Ayn par exemple, qui n'a que des posts au présent à son actif et n'est donc pas compté).

Ce petit classement tient par ailleurs uniquement compte de l'activité des personnages durant l'année qui vient de s'écouler, sans quoi il y aurait un biais envers les personnages plus anciens, ce qui rend le cas d'Ishtar d'autant plus incroyable quand on sait que le personnage a participé a 39 rp en 2018.

Better, Faster, Longer ?

Le premier aperçu nous a fourni une idée générale de la quantité de posts sur OPS ainsi que de leur répartition par personnage joueur, selon les temporalités de rp. Maintenant que nous avons cette vision d'ensemble, allons explorer plus en détail un autre aspect du contenu rp d'OPS : la longueur. Sujet de nombreux débats en son temps, la longueur est une composante majeure du système RP d'OPS. Nous allons donc la décliner à différente sauces et voir quelles conclusions peuvent en être tiré.

Plusieurs éléments peuvent entrer en ligne de compte lorsqu'on parle de longueur d'un rp. On peut se concentrer sur le nombre de pages, de pages word, de posts, de mots, de caractères, etc. De la manière dont on choisit d'interpréter la longueur découlera donc forcément des manières de noter différentes. Avant la 2e maj d'uniformisation des notations de mi-2014, ces critères étaient laissés à la libre interprétation du noteur (oui, c'était un peu le far-west).

Le nombre de post

Si on analayse la longueur en ne tenant uniquement compte du nombre de post, on peut intuitivement penser que ce nombre sera plus élevé pour les multis que pour les solos. Voyons voir si cette intuition se vérifie empiriquement.

In [49]:
solo_multi = pnd.read_csv('data/per_rp_multi_solo_ratio.csv', header=0)
In [50]:
solo_multi = solo_multi.sort_values('rid')
solo_multi = solo_multi.set_index('rid')
#solo_multi.head()
Out[50]:
title chrono nParticipants nPosts rpType
rid
98 Un voyage, incroyable dans un lieu macabre [Clos] P 1 1 SOLO
240 [flashback] Campagne contre les brigands des m... FB 1 1 SOLO
268 [Mission] C'est pas facile tous les jours P 1 1 SOLO
319 Une journée banale pour un Marine sur Shabondy P 1 1 SOLO
357 Partition mythique [Clos] P 1 1 SOLO
In [17]:
solo_multi['rClass'] = solo_multi.eval('chrono + rpType')
c = {
    'FBMULTI': '#DE0784',
    'PMULTI': '#DD0A0E',
    'FBSOLO': '#07C1E1',
    'PSOLO': '#0A0DCD'
}
numbers = {
    1:'one',
    2:'two'
}
solo_multi['cParticipants'] = solo_multi['nParticipants'].apply(lambda x: 3 if x > 2 else x)
solo_multi['colour'] = solo_multi['rClass'].apply(lambda x: c[x])
In [18]:
multi = solo_multi.loc[solo_multi['rpType'] == 'MULTI', 'nPosts']
solo = solo_multi.loc[solo_multi['rpType'] == 'SOLO', 'nPosts']
data = pnd.DataFrame({
    'MULTI': multi,
    'SOLO': solo
})
with plt.style.context('seaborn'):
    data.hist( bins=15, sharey=True)
#print(data['SOLO'].describe())
#print(data['MULTI'].describe())
count    228.000000
mean       1.706140
std        1.963669
min        1.000000
25%        1.000000
50%        1.000000
75%        1.000000
max       13.000000
Name: SOLO, dtype: float64
count    442.000000
mean      17.841629
std       14.287533
min        1.000000
25%        8.000000
50%       14.000000
75%       22.000000
max      107.000000
Name: MULTI, dtype: float64

En moyenne, un solo fait 1.7 posts de long alors qu'un multi fait en moyenne 17.5 posts.

In [19]:
with plt.style.context('seaborn'):
    plot = solo_multi.boxplot(column=['nPosts', 'nParticipants'], by=['rClass'], layout=(1,2), figsize=(15,8))

Nous avons ici deux groupes de 4 "boites à moustache". La partie gauche nous montre pour chaque catégorie de RP (à savoir les FB multi, FB solo, présents multi et présents solo) la répartition des posts. Pour comprendre comment le lire, prenons la boite à moustache "PMULTI". La majorité (50%) des rp multi au présent se situent à l'intérieur du rectangle (donc entre 15 et 25 posts). Parmi les 50% restants, 25% font moins de 15 posts et 25% font plus de 25 posts.

Avec cette grille de lecture, on voit donc sans grande surprise que les multis ont généralement bien plus de posts que les présents, mais aussi (moins évident) que les multis au présent sont globalement plus long que les multis en flashback. Sur ce dernier point, l'image de droite nous donne une piste d'explication. Cette fois-ci les boites à moustaches représentent le nombre de participants par type de rp. Vous constatez qu'à l'exception des multis au présent, les boites sont complètement aplaties. Les solos se font tout seul (incroyable!), les multis en flashback se font à deux et les multis en présents se font parfois à plus que deux.

In [20]:
dtype={'solos':np.int32, 'multis':np.int32, 'ratios':np.float64}
data_ms = pnd.read_csv('data/per_member_multi_solo_ratio.csv', header=0, sep='\t', dtype=dtype)
data_ms = data_ms.set_index('cname')
In [21]:
data_ms_g = pnd.read_csv('data/per_member_multi_solo_whole.csv', header=0, sep='\t', dtype=dtype)
data_ms_g = data_ms_g.set_index('cname')
ms_g = data_ms_g.loc[data_ms_g.index.isin(data_ms.index.values)]
ms = data_ms.loc[data_ms.index.isin(ms_g.index.values)]
ind = np.arange(len(ms_g['ratio'].values))
In [22]:
with plt.style.context('seaborn'):
    plt.figure(figsize=(20,10))
    ax = plt.subplot(111)
    p1 = ax.bar(ind, ms_g['ratio'], width=0.4, align='center')
    p2 = ax.bar(ind+0.3, ms['ratio'], width=0.4, align='center')
    
    plt.title('multi/solo ratio per member')
    plt.xticks(ind, ms_g.index.values, rotation='vertical')
    #plt.yticks(np.arange(0, 81, 10))
    plt.legend((p1[0],p2[0] ), ('in 2018', 'Since 2011'))
    plt.show()

Voici un classement des membres selon le ratio de multi / solo dans le courant de l'année dernière. Clyde ayant un ratio de 1 en bleu implique que le personnage est apparu dans autant de solo que de multis en 2018. A l'opposé, Edward a fait 10 fois plus de multi que de solos.

In [23]:
data_ppr = pnd.read_csv('data/post_per_rp.csv', header=0,sep='\t', dtype={'post_per_rp': np.float64})
data_ppr = data_ppr.set_index('cname')
with plt.style.context('seaborn'):
    plot = data_ppr['post_per_rp'].plot(figsize=(10, 20), kind='barh')
    plt.title('Nombre moyen de posts par rp (Hall of fame des spammers 2.0)')
    fig = plot.get_figure()
    fig.savefig('img/hist_PPR.png')

La longueur du texte

L'avantage avec le nombre de posts est qu'il est très simple à calculer, que ce soit pour un humain ou pour une machine. On peut donc facilement classer les personnages-joueurs selon leur moyenne de posts par rp, au cours de l'année dernière. En revanche, en se contentant uniquement du nombre de post comme indicateur de longueur, on perd de vue la véritable longueur des textes rédigés par les membres. Ce critère a désormais une place prépondérante dans la mesure de la longueur.

Vouloir utiliser la longueur du texte est une bonne chose, mais comment mesurer exactement cette dernière ? Par le nombre de lignes ? Par le nombre de phrases, de mots, ou de lettres ? A nouveau, OPS est passé par plusieurs phases et actuellement, dans le cas des présentations par exemple, seul le nombre de mots est pris en compte. Nous allons donc faire de même pour l'analyse qui suit, en essayant de voir si ceux qui écrivent les posts les plus longs sont également ceux qui postent le plus de posts par rp.

In [44]:
# add word counts of each post
posts_wc = pnd.read_csv('data/posts_wc.zip', header=0, parse_dates=['date'])
posts_wc = posts_wc.set_index('date')
posts = posts.join(posts_wc)
In [52]:
posts['SOLO'] = posts['rp_id'].apply(lambda x: 1 if len(posts.loc[posts['rp_id'] == x]) > 1 else 0)
Out[52]:
p_id text url rp_id P FB Total A M B wc SOLO
date
2011-09-05 21:31:00 115 La nouvelle année à Luvneel \nU\nne nouvelle ... http://www.op-seken.com/t60-la-nouvelle-annee-... 60 1 0 1 2011 9 2011-09-05 3079 0
2011-09-08 22:50:00 197 Journal de bord\n1er Septembre\nJe viens de pr... http://www.op-seken.com/t98-un-voyage-incroyab... 98 1 0 1 2011 9 2011-09-08 1232 0
2011-09-19 14:41:00 435 En cette belle matinée d’hiver, je fais le ple... http://www.op-seken.com/t178-mission-gars-du-g... 178 1 0 1 2011 9 2011-09-19 1849 0
2011-09-22 13:51:00 492 C\nela faisait déjà un bon moment que Mori mar... http://www.op-seken.com/t194-mission-lecon-de-... 194 1 0 1 2011 9 2011-09-22 1772 0
2011-09-24 21:25:00 590 Chimères...\nPourtant pas une illusion !\nTroi... http://www.op-seken.com/t225-chimeres-pourtant... 225 1 0 1 2011 9 2011-09-24 2346 0
In [61]:
with plt.style.context('seaborn'):
    plot = posts.resample('Q')['wc'].mean().plot(figsize=(18, 10), style=['-', '--', ':'])
    h, l = plot.get_legend_handles_labels()
    plot.legend(h, l)
    plot.set_ylabel('mean word count')
    plot.set_title('Nombre de mots par posts en moyenne au fil du temps')
    fig = plot.get_figure()
    fig.savefig("img/post_wc_time.png")
    plot

Ce graphe nous montre quelque chose d'intéressant : dans les premiers moments de la version actuelle d'OPS, les membres écrivaient tous des pavés. 1500 mots en moyenne par posts est un nombre qui reste largement supérieur à ce qui se fera par la suite (oublions l'hiver 2018 pour l'instant). C'est un ressenti que tous les anciens pourront confirmer, le style d'OPS était fortement tourné vers la quantité. C'est d'autant plus intéressant quand on se souvient du barème de l'époque et surtout de la manière dont la quantité était comptabilisée (comme je l'ai déjà dit souvent, c'était le far-west). Cette tendance à écrire de très longs posts était déjà présente sur la V1.

Du coup grosse chute au premier quadrimestre de 2012, signe d'une période de crise. La Wayback Machine de la page d'accueil du forum ne remonte malheureusement que jusqu'en 2013, mais ceux qui étaient sur le forum à l'époque doivent se rappeler de cette période où le forum a faillit dispraitre pour vous situer le contexte: en février 2012, Raphael quitte le forum, qui subit en même temps une vague de ghostage (staff inclus). La situation devient critique et en juillet 2012, un certain modo particulièrement actif nommé Nakata passe admin.

Le graphe illustre bien ce qu'il s'est passé à la sortie de cet âge sombre concernant la longueur : on écrit moins long, beaucoup moins long. A partir de cette époque, OPS tourne autour des 1000 mots par posts et si vous reprenez le précédent graphe sur le nombre de posts étalés dans le temps, le timing coïncide parfaitement avec le moment où le nombre de posts produit a commencé à exploser. OPS se met donc à écrire moins long, mais beaucoup plus souvent.

Fast forward jusqu'en hiver 2018. Lancement de l'event tant attendu, qui vient couronner une année qui a battu nombreux records en terme d'activité. Qu'est-ce qu'on remarque ? La taille moyenne des posts a considérablement augmenté durant l'event. C'était le cas pour les events précédent, mais pas avec une telle intensité. Il serait intéressant d'investiguer ce point davantage pour essayer d'en identifier les causes sous-jacentes, mais je ne le ferai pas ici par souci de concision. A voir si c'est passagé ou si c'est une tendance qui va persister.

In [ ]: