État de l'emploi bioinformatique en France : analyse des offres de la SFBI (2ème partie)

Nous revoi­là pour la suite de notre pre­mier article sur l'analyse des offres de la SFBI. On vous avait pro­mis une ana­lyse de l'évolution du mar­ché, et c'est ce dont nous allons par­ler dans cet article.

Je vous ren­voie au pre­mier article si vous vou­lez plus d'informations sur l'origine des don­nées et la dis­po­ni­bi­li­té du code. Les contri­bu­tions sur le Github du pro­jet ont été bien ternes… ou plu­tôt inexis­tantes. C'est bien dom­mage car nous nour­ris­sions le secret espoir de publier ici vos meilleurs graphes.

Le code n'est pro­ba­ble­ment pas facile à prendre en main. Nous avons donc choi­si un for­mat proche d'un jupy­ter note­book pour ce second article : le code qui génère les graphes est ain­si direc­te­ment dis­po­nible. L'article est, de fait, un poil plus tech­nique que le pré­cé­dent, mais nous espé­rons que cela sus­ci­te­ra des voca­tions pour amé­lio­rer nos graphes, et sur­tout, en créer d'autres. Notons qu'il s'agit d'un excellent exemple de l'utilisation du module python Pan­das sur des don­nées réelles.

Chargement des modules nécessaires et des données

On entre donc dans le vif du sujet en com­men­çant par char­ger les don­nées conte­nues dans la base de don­né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 utils
with 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ée

axx[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()

Evolution du nombre d'offres par bimestre

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ée

axx[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()

Evolution du nombre d'offres par bimestre (en plus joli)

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')
Evolution du nombre d'offre par type de contrat
Évolution du nombre et de la proportion d'offres par type de contrat.

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')
Évolution du nombre d'offres par bimestre par type de CDD
Évolution du nombre et de la proportion d'offres par bimestre par type de CDD.

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')
Évolution des nombres et proportions de CDD par catégorie de durée.
Évolution des nombres et proportions de CDD par catégorie de durée.

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.



Pour continuer la lecture :


Commentaires

4 réponses à “État de l'emploi bioinformatique en France : analyse des offres de la SFBI (2ème partie)”

  1. Avatar de Daniel Gautheret
    Daniel Gautheret

    Mer­ci pour ce tra­vail super inté­res­sant pour les étu­diants aus­si bien que les profs.
    Si cela vous inté­resse j'ai gar­dé tous les mails d'offres d'emploi dif­fu­sés sur la liste Bioin­fo depuis 2001 !

    1. Mer­ci de ce retour. Le pro­blème des mails c'est que les offres ne sont pas for­ma­tées, donc ça rend très dif­fi­cile les ana­lyses, c'est pour ça qu'on s'est concen­trés sur les offres du site.

      On réflé­chit actuel­le­ment à un moyen d'en dire quand même quelque chose (on les a aus­si sto­ckés), mais bon c'est pas trop la bonne période niveau dis­po­ni­bi­li­té. Vous aurez sûre­ment des nou­velles d'ici la ren­trée 😉 .

  2. Super inté­res­sant le détail et les expli­ca­tions, mer­ci !

    Par contre, les offres cumu­lées prennent-elles en compte d'éventuelles vacances mul­tiples pour le même poste ? Il serait inté­res­sant aus­si, par exemple, de voir ce que ça donne en nombre de poste pour les CDD en sup­pri­mant les postes au bout de leur durée, mais ça ne pren­drait pas en compte les renou­vel­le­ments. Pas facile de lire ces don­nées !

    1. Ce genre de sub­ti­li­té n'a pas été pris en compte, c'est quelque chose de très com­pli­qué à faire. Tous ces chiffres res­tent des approxi­ma­tions. Néan­moins ça serait très inté­res­sant, je suis d'accord.

Laisser un commentaire