Nous revoilà pour la suite de notre premier article sur l'analyse des offres de la SFBI. On vous avait promis une analyse de l'évolution du marché, et c'est ce dont nous allons parler dans cet article.
Je vous renvoie au premier article si vous voulez plus d'informations sur l'origine des données et la disponibilité du code. Les contributions sur le Github du projet ont été bien ternes… ou plutôt inexistantes. C'est bien dommage car nous nourrissions le secret espoir de publier ici vos meilleurs graphes.
Le code n'est probablement pas facile à prendre en main. Nous avons donc choisi un format proche d'un jupyter notebook pour ce second article : le code qui génère les graphes est ainsi directement disponible. L'article est, de fait, un poil plus technique que le précédent, mais nous espérons que cela suscitera des vocations pour améliorer nos graphes, et surtout, en créer d'autres. Notons qu'il s'agit d'un excellent exemple de l'utilisation du module python Pandas sur des données réelles.
Chargement des modules nécessaires et des données
On entre donc dans le vif du sujet en commençant par charger les données contenues dans la base de données.
from __future__ import unicode_literals, print_function
import matplotlib
matplotlib.use('TkAgg')
%matplotlib inline
matplotlib.rcParams['figure.dpi'] = 100 # bigger figures in notebook mode
matplotlib.rcParams['savefig.dpi'] = 100 # bigger figures in inline mode
colors = ['#6f3883', '#87ad3e', '#fce33e', '#4066c7', '#cc3428'] # colorpalette from bioinfo-fr
import json, os
from bson import json_util
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mpldates
import numpy as np
import utilswith open('../../resources/jobs_anon.json', 'r') as input_file: # emplacement de la BDD
job_list = utils.load_from_json(input_file) # création de la liste de jobs
print('Nombre total d\'offres', len(job_list))
print('Champs :', job_list[0].keys())Nombre total d'offres : 1437
Champs : ['submission_date', 'contract_subtype', 'validity_date', 'duration', 'contract_type', 'city', 'title', 'limit_date', u'department', 'starting_date', u'region']On constate que l'on a gagné environ 150 offres depuis mi-février. Vous pouvez voir que chaque offre dans job_list est un dictionnaire qui contient 11 clés. Comme ce n'est pas très pratique à manipuler, on va créer ce que l'on appelle un dataframe grâce au module Pandas. Au passage on ne va récupérer que les champs potentiellement intéressants pour cet article.
df = pd.DataFrame(job_list, columns=['contract_type',
'contract_subtype',
'duration',
'submission_date'])
df.submission_date = pd.to_datetime(df.submission_date)
df.describe() # On affiche un résumé du dataframe.
contract_type | contract_subtype | duration | submission_date | |
---|---|---|---|---|
count | 1437 | 1437 | 1437 | 1437 |
unique | 4 | 11 | 33 | 671 |
top | CDD | 6 | 2015-11-09 00:00:00 | |
freq | 785 | 412 | 253 | 12 |
first | NaN | NaN | NaN | 2012-04-23 00:00:00 |
last | NaN | NaN | NaN | 2016-05-19 00:00:00 |
Ce petit tableau généré par la fonction describe de Pandas est très pratique. Il nous donne pour chaque champ le nombre total d'offres, le nombre de champs uniques (par exemple, 4 types de contrats), celui qui ressort le plus souvent (les CDD), et le nombre de fois qu'il ressort (785 fois). On peut également voir que la première offre date du 23 avril 2012, et que la dernière est du 19 mai 2016 (date à laquelle a été mise à jour la base de données).
Évolution du nombre d'offres sur la période 2012-2016
Premier exemple avec un petit graphe tout simple : peut-on tracer l'évolution de la quantité d'offres au cours du temps ? On peut obtenir ça très facilement en quelques lignes avec la puissance du module Pandas.
# On prend la colonne submission_date du dataframe et on rééchantillonne
# par bimestre (pour plus de lisibilité)
date_serie = df.submission_date.value_counts().resample('2M', how='sum')
date_serie.head() # On affiche un aperçu du résultat (5 premières lignes)
2012-04-30 3
2012-06-30 9
2012-08-31 10
2012-10-31 20
2012-12-31 20
Freq: 2M, Name: submission_date, dtype: int64
Comme vous pouvez le voir, on obtient en une seule ligne de code l'information demandée, il ne reste plus qu'à optimiser l'affichage.kwargsplot = {'marker': '+', 'markersize': 10, 'color': colors[3], 'lw': 1, 'ls': '-'}
fig, axx = plt.subplots(1,2, figsize=(9,4))
date_serie.plot(ax=axx[0], **kwargsplot) # on affiche la somme par bimestre
date_serie.cumsum().plot(ax=axx[1], **kwargsplot) # on affiche la somme cumuléeaxx[0].set_title(u'Nombre d\'offres par bimestre')
axx[0].set_ylabel("Nombre d'offres")
axx[1].set_title(u'Somme cumulée')
axx[1].set_ylabel("Total d'offres")
for ax in axx.flatten():
ax.set_xlabel('Date')
fig.tight_layout()
Puissant n'est-ce pas ?
Nous avons décidé de regrouper les données par bimestre (tranches de 2 mois) car les données sont très bruitées d'un mois sur l'autre, ce qui rend la visualisation difficile. Les points correspondent à des fins de mois et comprennent toutes les offres des 2 mois précédents (par exemple, un point au 31 décembre comprend toutes les offres du 1er novembre au 31 décembre). Notons ainsi que les graphes vont jusqu'au 30 juin, le dernier bimestre est donc inévitablement sous-évalué puisqu'il est encore en cours au moment de la rédaction de l'article.
On peut aussi utiliser le sous-module dates de matplotlib pour obtenir un graphe légèrement plus élaboré (avec par exemple, une meilleure lisibilité des mois de l'année). Cela prend quelques lignes de plus car nous n'utilisons plus les méthodes de visualisation du module Pandas.
dd, mm = date_serie.index.to_pydatetime(), date_serie
cummm = date_serie.cumsum()kwargsplot = {'marker': '+', 'markersize': 10, 'color': colors[3], 'lw': 1, 'ls': '-'}
fig, axx = plt.subplots(2,1, figsize=(9,5))
axx[0].plot_date(dd, mm, **kwargsplot) # on affiche la somme par mois
axx[1].plot_date(dd, cummm, **kwargsplot) # on affiche la somme cumuléeaxx[0].set_title(u'Nombre d\'offres par bimestre')
axx[0].set_ylabel("Nombre d'offres")
axx[1].set_title(u'Somme cumulée des offres')
axx[1].set_ylabel("Total d'offres")
for ax in axx.flatten():
ax.xaxis.set_minor_locator(mpldates.MonthLocator(bymonth=(1),interval=1))
ax.xaxis.set_minor_formatter(mpldates.DateFormatter('%m'))
ax.xaxis.grid(True, which="minor")
ax.xaxis.set_major_locator(mpldates.YearLocator())
ax.xaxis.set_minor_locator(mpldates.MonthLocator())
ax.xaxis.set_major_formatter(mpldates.DateFormatter('\n\n%Y'))
fig.tight_layout()
On voit que la fréquence de publication des offres augmente au cours du temps. On devine aussi quelques variations saisonnières, avec notamment un creux chaque année autour de la période estivale. Mais depuis plusieurs mois, la liste s'est maintenue au dessus de 1 offre par jour. À l'heure actuelle, le mois le plus riche en offres est le mois de novembre 2015 avec une moyenne de plus de 2,5 offres par jour. Il fait partie du bimestre le plus haut sur le graphe du haut.
Notons que la SFBI (et la liste de diffusion bioinfo) pousse de plus en plus à passer par son site internet, ce qui pourrait facilement expliquer cette croissance (notre jeu de données ne prend en compte que les offres hébergées par le site). Une question demeure, cependant : dans quelle mesure la progression observée ici reflète-t-elle la progression réelle de l'offre en bioinformatique ? En effet, tous les recruteurs ne passent pas nécessairement par la SFBI pour diffuser leurs offres. Il est donc toujours nécessaire de garder un peu de recul face à ces données.
Évolution des types d'offres sur la période 2012-2016
De quels types d'offres parle-t-on ? Est-ce que comme l'a évoqué Sophie Schbath, présidente de la SFBI, la proportion de contrats courts augmente, ou s'agit-il d'un biais de perception ? Une première analyse consisterait à tracer le graphe précédent par type de contrat, ce qui se fait là encore très facilement avec Pandas.
df_per_type = df[['submission_date', 'contract_type']] # On crée un sous dataframe
df_per_type = df_per_type.set_index('submission_date') # On met les dates en index
gb = df_per_type.groupby(pd.TimeGrouper('2M')) # On groupe par période de 2 mois
# Enfin, on compte chaque occurence de contrat dans les périodes définies
df_count_per_type = gb.contract_type.apply(lambda x: x.value_counts()).unstack().fillna(0)
df_count_per_type.head() # On affiche un petit aperçu
CDD CDI Stage Thèse submission_date 2012-04-30 1 0 1 1 2012-06-30 6 0 0 3 2012-08-31 9 1 0 0 2012-10-31 15 3 2 0 2012-12-31 11 2 7 0 fig, axx = plt.subplots(1,2, figsize=(9,4))
df_count_per_type.plot(ax=axx[0], kind='line', color=colors, lw=2)
# On calcule les proportions sur les CDD, CDI et Thèses uniquement
df_proportion = df_count_per_type.drop('Stage',1).apply(lambda c: c/c.sum()* 100, axis=1)
# Les couleurs sont ajustées pour correspondre à l'ommision de la courbe 'Stage'
colors_nostage = [colors[0], colors[1], colors[3]] df_proportion.plot(ax=axx[1], kind='line', color=colors_nostage, lw=2, legend=False)
# On définit proprement les titres et étiquettes de chaque graphe
axx[0].set_title("Nombre d'offres par bimestre")
axx[0].set_ylabel("Nombre d'offres")
axx[1].set_title("Proportion des offres")
axx[1].set_ylabel("% des offres")
for ax in axx.flatten():
ax.set_xlabel('Date')
ax.grid()
ax.set_axisbelow(True)
fig.tight_layout()
fig.savefig('../../output/evo_contrats.svg')
Le graphe de gauche donne la quantité d'offres par type de contrat chaque bimestre depuis l'ouverture du site. Le graphe de droite représente la proportion de trois types d'offres comparables : CDD, CDI et Thèse (les stages ne touchent pas le même public). On constate tout d'abord que les CDD sont majoritaires sur toute la période. Bien que leur proportion ait diminué depuis l'ouverture du site, elle semble s'être stabilisée depuis 2014 autour de 70%. Chaque mois, un abonné à la liste a ainsi le choix entre environ 20 CDDs, 7 CDI et 3 thèses. On n'observe cependant pas une augmentation de la proportion de CDD comme imaginé initialement. Cette impression venait probablement du fait que le nombre de CDD postés sur le site a énormément augmenté, et que l'on est moins attentifs au fait que toutes les autres offres l'ont fait dans les mêmes proportions.
On devine quelques variations saisonnières pour les contrats très connotés "académique" comme les stages et les thèses : on trouve généralement les thèses au début de l'année, et les stages ne semblent exister que sous forme d'un énorme pic d'octobre à décembre (point culminant en novembre). Les autres contrats ne sont pas exempts de telles variations puisqu'on observe par exemple un pic de CDI pendant les mois de janvier-février 2014 et 2015. Ce phénomène ne semble pas s'être reproduit en 2016 (avec à la place, un pic de CDD). Certains membres de l'équipe l'expliquent par l'actualité et les incertitudes qui y sont associées (modification possible de ces contrats dans quelques semaines). Pour d'autres, cela peut simplement venir de la baisse du nombre de postes dans le public, ou d'une catégorie d'offres qui n'apparaîtrait plus sur la liste. Cela mériterait une analyse plus fine. Quels types de CDI ont disparu ? Quels types de CDD sont apparus ? Un cookie à qui trouvera la meilleure explication (données à l'appui).
Évolution des types de CDD sur la période 2012-2016
On rappelle que l'on a 4 sous-catégories de CDD, telles que définies sur le site de la SFBI au moment d'entrer une offre : Post-doc / IR, CDD Ingénieur, ATER et CDD autre. Le jeu de données ne comprend que 2 offres ATER sur toute la période, on va donc exclure cette catégorie de l'analyse. Pour rappel, les post-docs occupaient à eux seuls 50% du nombre de CDD publiés dans notre dernier article. Cette proportion est-elle variable au cours du temps ?
# On filtre pour ne garder que les CDDs, sans les ATER
df_CDD = df[(df['contract_type'] == 'CDD') & (df['contract_subtype'] != 'ATER')] # On crée une sous dataframe
df_per_CDD_subtype = df_CDD[['submission_date', 'contract_subtype']] # On met les dates en index
df_per_CDD_subtype = df_per_CDD_subtype.set_index('submission_date')
# On groupe par période de 2 mois
gb = df_per_CDD_subtype.groupby(pd.TimeGrouper('2M'))
# On compte chaque occurence de contrat
df_count_per_subtype = gb.contract_subtype.apply(lambda x: x.value_counts()).unstack().fillna(0)
df_count_per_subtype.head()
CDD Ingénieur CDD autre Post-doc / IR submission_date 2012-04-30 0 0 1 2012-06-30 2 0 4 2012-08-31 3 0 6 2012-10-31 10 2 3 2012-12-31 2 3 6 fig, axx = plt.subplots(1,2, figsize=(9,4))
df_count_per_subtype.plot(ax=axx[0], kind='line', color=colors, lw=2)
df_proportion = df_count_per_subtype.apply(lambda c: c/c.sum()* 100, axis=1)
df_proportion.plot(ax=axx[1], kind='line', color=colors, lw=2, legend=False)
axx[0].set_title("Nombre d'offres par bimestre (CDD)")
axx[0].set_ylabel("Nombre d'offres")
axx[1].set_title("Proportion des offres (CDD)")
axx[1].set_ylabel("% des offres")
for ax in axx.flatten():
ax.set_xlabel('Date')
ax.grid()
ax.set_axisbelow(True)
fig.tight_layout()
fig.savefig('../../output/evo_CDD.svg')
Il semblerait que le parent pauvre des sous-types de CDD soit le CDD autre. On observe massivement des CDD ingénieur ou Post-doc/IR, dans des proportions et augmentations comparables (les Post-doc/IR sont légèrement plus représentés). Les proportions sont stables depuis les débuts du site, l'analyse temporelle n'apporte ainsi pas grand chose sur cet aspect. Une séparation entre privé et public serait intéressante, mais nous n'avons pas les données nécessaires pour le faire à l'heure actuelle.
Évolution de la durée des CDD sur la période 2012-2016
Une donnée à laquelle nous avons cependant accès est la durée de ces CDD. Les contrats signés ont-ils tendance à l'être pour des durées plus courtes ou plus longues ? Comme nous l'avons vu dans l'article précédent, la nature discrète des durées des contrats (seulement 33 valeurs uniques sur plus de 1400 offres, d'après le tableau en début d'article) rend les distributions de durées très asymétriques. Il peut donc être intéressant de visualiser ça sous forme de catégories.
# On définit nos catégories
list_categories = [u'≤ 1 an',
u'1 < x ≤ 2 ans', u'> 2 ans'] # On crée une fonction qui retourne la catégorie à partir de la durée en mois
def get_category(months):
cat_num = months//12
div_remain = months%12
if div_remain == 0: cat_num -= 1# pour avoir ≤ et non pas <
if cat_num < 2:
return list_categories[cat_num] else:
return list_categories[-1]is_CDD = df.contract_type == 'CDD'
is_sanitized = (0 < df.duration) & (df.duration < 100)
df_duration = df[is_CDD & is_sanitized][['duration', 'submission_date']] df_duration['category'] = df_duration.loc[:,'duration'].apply(get_category)
df_duration = df_duration.set_index('submission_date')
# On groupe par période de 2 mois
gb = df_duration.groupby(pd.TimeGrouper('2M'))
# On compte chaque occurence de contrat
df_count_per_duration = gb.category.apply(lambda x: x.value_counts()).unstack().fillna(0)
df_count_per_duration = df_count_per_duration.reindex(columns=list_categories)
df_count_per_duration.head()
≤ 1 an 1 < x ≤ 2 ans > 2 ans submission_date 2012-04-30 0 1 0 2012-06-30 2 3 0 2012-08-31 2 5 2 2012-10-31 7 6 0 2012-12-31 3 4 3 fig, axx = plt.subplots(1,2, figsize=(9,4))
df_count_per_duration.plot(ax=axx[0], kind='line', color=colors, lw=2)
df_proportion = df_count_per_duration.apply(lambda c: c/c.sum()* 100, axis=1)
df_proportion.plot(ax=axx[1], kind='line', color=colors, lw=2, legend=False)
axx[0].set_title("Nombre d'offres par bimestre (durée CDD)")
axx[0].set_ylabel("Nombre d'offres")
axx[1].set_title("Proportion des offres (durée CDD)")
axx[1].set_ylabel("% des offres")
for ax in axx.flatten():
ax.set_xlabel('Date')
ax.grid()
ax.set_axisbelow(True)
fig.tight_layout()
fig.savefig('../../output/evo_duree.svg')
Finalement sur l'ensemble des CDD, les contrats courts (≤ 1 an) et les contrats moyens (entre 1 et 2 ans) augmentent à la même vitesse, et occupent donc des proportions comparables. Les contrats longs (plus de 2 ans) se maintiennent à une proportion constante sur la période. On a également essayé de tracer ça par type de contrat, mais nous n'avons pas vu de différences claires. On laisse à nos lecteurs curieux le soin d'explorer cette voie par eux même.
Au final, il n'y a pas d'argument majeur soutenant la thèse d'une diminution de la durée des contrats. On constate cependant un pic de contrats courts début 2016. Reste à voir si la tendance va se confirmer sur le reste de l'année.
En conclusion, on a vu que l'on pouvait très facilement explorer ce jeu de données avec des commandes Pandas bien construites. Il y a probablement plein d'aspects auxquels nous n'avons pas pensé, et on compte sur vous pour nous aider. Les meilleurs graphes proposés sur Github ou dans les commentaires seront en effet au cœur de l'article suivant.
Merci à Akira, Kumquatum, lroy et Sylvain P. pour leurs commentaires et améliorations lors de la relecture de cet article. Merci à lroy pour sa gestion de la base de données et son aide dans la réalisation des graphes et la rédaction de cet article.
Laisser un commentaire