In [507]:
%matplotlib inline

from matplotlib import pyplot as plt
from matplotlib import cm

import pandas as pd
import numpy as np
import seaborn as sns
import datetime as dt
from tabulate import tabulate
import re
import math

sns.set_palette("deep", desat=.6)
sns.set_context(rc={"figure.figsize": (16, 6), "lines.linewidth": 1.5}, font_scale=2)
In [573]:
# Load data
content = pd.read_pickle("data/content_04012015-04032015.pkl")
content['id'] = content.index
content["published"] = content.url.apply( \
    lambda x: re.match(".*\/(?P<date>[0-9]{4}\/[0-9]{2}\/[0-9]{2}).*\.html", x).group("date"))

hits = pd.read_pickle("data/hits_04012015-04032015.pkl")
refday = dt.date(2015,3,4)
refday_col = refday.strftime("%Y_%m_%d")
In [603]:
# Utilities

def get_timeline(hits, id, length = 60):
    tl = hits.loc[id]
    tl.index = [dt.datetime.strptime(x, "%Y_%m_%d").date() for x in tl.index]
    tl = tl.sort_index()
    last = tl.index[-1]
    return tl[last - dt.timedelta(days=length):last].sort_index()

def tmplot(aids, hits, content, ymax=0, fs=(17,6), title="", ylabel="", xlabel="", lgy=False):
    plt.figure(figsize=fs)
    cs=cm.Accent(np.linspace(0,1,len(aids)))
    for idx, aid in enumerate(aids):
        tm = get_timeline(hits, aid)
        info = content.loc[aid]
        tm.plot(label='{} - {} ({}, {})'.format(info.rubrique, info.title, info.published, info.id), c=cs[idx], logy=lgy)
    
    if ymax: plt.ylim(0,ymax)
    if title: plt.title(title, fontsize=16, y=1.07)
    if ylabel: plt.ylabel(ylabel)
    if xlabel: plt.xlabel(xlabel)
    plt.legend()
    plt.show()
    
def list_content(clist, size=10):
    def strtrunc(x, l):
        if len(x) > l: return x[:l] + "…" 
        else: return x
    
    data = clist.head(size).copy()
    data.title = data.title.apply(strtrunc, l=38)
    data.rubrique = data.rubrique.apply(strtrunc, l=13)
    data.index = data.id.apply(str)
    print(tabulate(data[["title", "rubrique", "published", "hits"]], headers=["TITLE", "RUBRIQUE", "DATE PUB.", "VIS."]))
    return

A la recherche d'Evergreen

Rodolfo Ripado @ Le Monde // Mars 2015

Contenu evergreen : intemporel, de qualité, canonique

« Quality, useful content that is relevant to readers for a long period of time. »

« [Content that] can be linked to and gain traffic long after it is originally published. »

« Will people still read this and think it’s interesting a year from now ? »

Exemples

  • Guides, listes de conseils sur des sujets pérennes
  • Définitions, FAQs
  • Posts de contexte politique, historique, social

Il y a-t-il du evergreen sur LeMonde.fr ?

La majorité du contenu du Monde est anti-evergreen par définition :

  • Actu « chaude »
  • Données chiffrées très contextuelles
  • Opinitions politiques, critiques des spectacles
  • Suivi d'évènements

Evergreen : où es-tu ?

  • Comment trouver les contenus (potentiellement) evergreen ?
  • En avons-nous actuellement ?
  • À quoi ressemblent-ils ?
  • Quelle(s) stratégie(s) les exploiter / en créer davantage ?

Comment j'ai mené ma petite enquête

ou AT internet : c'est d'la balle !

In [604]:
aids = [4569221, 4549206, 4549610, 4569862]
tmplot(aids, hits, content, fs=(10,7), title="La vie de quelques articles", ylabel="Visites", xlabel="Dates")

Au menu

  1. Comprendre le trafic quotidien sur les articles du monde (# visites, âge des contenus)
  2. Trouver un critère qui nous permette d'isoler les contenus evergreen
  3. Voir si cela fonctionne et ce qu'on peut en faire

Mer. 4 mars 2015 - une journée comme les autres

In [496]:
# Subsample daily data
daydata = hits[refday_col].copy()
daydata.name = "hits"
daydata = daydata[daydata > 0]
daycontent = pd.merge(content.copy(), pd.DataFrame(daydata), left_index=True, right_index=True, how="inner")
daycontent["age"] = daycontent.published.apply( \
    lambda x: (refday - dt.datetime.strptime(x, "%Y/%m/%d").date()).days)
daycontent["published_dt"] = daycontent.published.apply( \
    lambda x: dt.datetime.strptime(x, "%Y/%m/%d").date())
In [469]:
print("Total articles read: ", len(daycontent))
print("Total views: ", int(daydata.sum()))
print("Total rubriques uniques: ", len(daycontent.rubrique.unique()))
print("Âges: du jour-même à plusieurs années.")
Total articles read:  48241
Total views:  1711346
Total rubriques uniques:  237
Âges: du jour-même à plusieurs années.

  • 50K articles différents lus
  • 1,7MM de visites sur des pages article
  • 237 rubriques différentes

Le top 10 du 3 mars

In [578]:
list_content(daycontent.sort(columns="hits", ascending=False))
         TITLE                                    RUBRIQUE     DATE PUB.      VISITES
-------  ---------------------------------------  -----------  -----------  ---------
4586979  genevieve_fioraso_veut_quitter_le_gouv…  politique    2015/03/04       94344
4587221  pour_taubira_les_propos_du_maire_de_to…  politique    2015/03/04       50700
4587403  nouveaux_remous_au_world_press_photo     arts         2015/03/04       43352
4586967  trois_suspects_identifies_trente_trois…  societe      2015/03/04       42673
4586674  detecteur_de_fumee_plus_que_cinq_jours…  immobilier   2015/03/03       38451
4586182  un_requin_lutin_peche_au_large_de_l_au…  planete      2015/03/03       36053
4586892  paris_a_nouveau_survolee_par_une_dizai…  societe      2015/03/04       29530
4587484  decouverte_exceptionnelle_en_france_d_…  archeologie  2015/03/04       29413
4586215  le_penis_de_long_en_large                sciences     2015/03/03       28848
4586899  areva_annonce_un_plan_d_un_milliard_d_…  economie     2015/03/04       28110

  • Articles publiés le jour même ou la veille
  • Rubriques habituelles : politique, internationale, sciences, décodeurs, ...
  • Énorme différence entre les visites des premiers et celles des autres ...

Comment les visites sont-elles distribuées à travers l'ensemble des articles lus dans une journée ?

Au-delà des tops: la longue traîne du monde

In [580]:
sorted_hits = daycontent.sort(ascending=False, columns="hits").hits.values
plt.figure(figsize=(16,5))
plt.plot(sorted_hits)
plt.title("Distribution des visites par article le 4/3/2015", fontsize=14)
plt.ylabel("Visites")
plt.xlim(0,1000)
plt.xlabel("Les 1000 articles les plus vus (sur un total de {})".format(len(sorted_hits)))

plt.axvline(200, label="à droite : moins de 873 visites", color="blue")
plt.axvline(600, label="à droite : moins de 149 visites", color="red")

plt.legend()
plt.show()
  • Quelques contenus représentent la plupart des visites
  • La surface d'exposition du site est dédiée à maximier la visibilité de ces quelques contenus
  • Les "petits" contenus ne servent presque qu'à attirer des visiteurs vers les "grands" contenus.

La longue traîne : un levier pour le trafic

On peut s'en servir pour augmenter le trafic global sur le site :

  • L'allonger : ajouter plus de pages « de niche »
  • L'épaissir : produire et valoriser des contenus evergreen

Pour cela, il faut qu'on connaisse un peu mieux le contenu de cette longue traîne

In [581]:
max = 60000
min = 1

vals = daycontent.hits.values
plt.figure(figsize=(16,4))
n, bins, patches = plt.hist(vals, log=True, bins = 10 ** np.linspace(np.log10(min), np.log10(max), 10))
plt.gca().set_xscale("log")
plt.gca().set_yscale("log")
plt.xlim(0,max)
plt.ylim(1,100000)
plt.title('Fréquences des visites sur un jour', fontsize=14)
plt.xlabel('Visites (log)')
plt.ylabel("Nombre d'articles (log)")
plt.show()
In [582]:
n_nb = len(n)
total_n = sum(n)
total_v = sum(vals)
bin_count_p_c = 1
views_p_c = 1
views_p_prev = 0
bin_count_p_prev = 0

res = []
for i in range(0,n_nb):
    low = math.floor(bins[i])
    if low != 1:
        low += 1
    if i < n_nb - 1:
        high = math.floor(bins[i+1])
    else:
        high = vals.max()
    
    bin_count = int(n[i])
    bin_count_p = n[i]/total
    views = np.sum(vals[np.logical_and(vals >= low, vals <= high)])
    views_p = views/total_v
    
    views_p_c -= views_p_prev
    bin_count_p_c -= bin_count_p_prev    
    res.append(("{:.0f} - {:.0f}".format(low, high), \
                int(n[i]), \
                "{0:.2%}".format(bin_count_p), \
                "{0:.2%}".format(views_p), \
                "{0:.2%}".format(bin_count_p_c), \
                "{0:.2%}".format(views_p_c)))
    
    views_p_prev = views_p
    bin_count_p_prev = bin_count_p

print(tabulate(res[::-1], headers=["VISITES", "# ART.", "% ART.", "% VIS.", "% ART. TOTAL", "% V. TOTAL"], stralign="right"))
      VISITES    # ART.    % ART.    % VIS.    % ART. TOTAL    % V. TOTAL
-------------  --------  --------  --------  --------------  ------------
17671 - 94344        21     0.04%    40.23%           0.04%        40.23%
 5205 - 17670        43     0.09%    20.60%           0.13%        60.83%
  1533 - 5204        81     0.17%    14.14%           0.30%        74.97%
   452 - 1532       155     0.32%     7.37%           0.62%        82.34%
    133 - 451       339     0.70%     4.78%           1.32%        87.12%
     40 - 132       872     1.81%     3.47%           3.13%        90.59%
      12 - 39      2848     5.90%     3.38%           9.04%        93.97%
       4 - 11      8427    17.47%     3.01%          26.50%        96.98%
        1 - 3     35454    73.50%     3.02%         100.00%       100.00%

Quel age ont les articles les plus lus ?

Prenons les articles du top 2% :

  • 965 articles sur 48K
  • 89% des vues
  • Les 98% restants ont moins de 72 visites / jour
In [497]:
# sub5p : 5% articles with the most views (2346 of 48241)
q = int(daycontent.hits.quantile(0.98))
subp = daycontent[daycontent.hits > q]

print("Top 2% articles ({}) have {:.2%} of the views".format(len(subp), subp.hits.sum()/daycontent.hits.sum()))
print("All the other 98% have less than {:.0f} views".format(subp.hits.min()))
Top 2% articles (965) have 88.91% of the views
All the other 98% have less than 72 views

In [590]:
plt.figure(figsize=(16,4))
n, bins, patches = plt.hist(subp.age.values[subp.age.values < 1000], log=True, bins=100)
plt.title('Fréquences des ages des articles du top 2%', fontsize=14)
plt.xlabel('Nombre de jours après publication')
plt.ylabel("Nombre d'articles (log)")

plt.show()
In [592]:
bin_nb = len(n)
article_nb = sum(n)
total_views = daycontent.hits.sum()
res = []
for i in range(0,bin_nb):
    low = math.floor(bins[i])
    high = math.floor(bins[i+1])
    if low != 0: 
        low += 1
    bin_count = int(n[i])
    bin_count_p = n[i]/article_nb

    bin_views_p = daycontent[(daycontent.age >= low) & (daycontent.age <= high)].hits.sum() / total_views
    
    res.append(("{:.0f} - {:.0f}".format(low, high), \
                int(n[i]), \
                "{0:.2%}".format(bin_count_p), \
                "{0:.2%}".format(bin_views_p)))

    if i > 5: break
print(tabulate(res, headers=["AGE (JOURS)", "# ART.", "% ART.", "% VUES"]))
print("...")
AGE (JOURS)      # ART.  % ART.    % VUES
-------------  --------  --------  --------
0 - 9               598  63.28%    83.67%
10 - 19              87  9.21%     2.47%
20 - 29              65  6.88%     1.69%
30 - 39              12  1.27%     0.53%
40 - 49              29  3.07%     0.66%
50 - 59              12  1.27%     0.45%
60 - 69               8  0.85%     0.44%
...

Quelques chiffres révélateurs

  • 40% des visites ont été réalisées par 21 articles (0.04%)
  • 90% des visities ont été réalisées par 3% des articles (~850)
  • 98% des articles (47K) ont eu moins de 72 visites dans la journée

Quelques chiffres révélateurs

  • 84% des vues sont faites par des articles de moins de 10 jours.
  • 50% des vues sont faites par des articles publiés le jour même, 72% si on compte ceux de la veille.

Tout ça coûte très cher !

Un tel site coûte horriblement cher à produire : des contenus "jetables", éphémères

  • Les rédactions doivent réinventer quotidiennement +50% de leur "chiffre d'affaires"
  • Re-écriture régulière des mêmes éléments de contexte, à l'occasion de nouveaux événements

On pourrait mitiger cette dépendance à la production quotidienne avec un peu de Evergreen

A la recherche d'Evergreen

Essayons de trouver ces « vieux » articles qui « rapportent » toujours.

Les « vieux » contenus du top 2%

Quelques articles de plus de 3 mois dans le top 2% (965 articles, 89% des vues)

In [587]:
list_content(subp[subp.age > 90].sort(columns="hits", ascending=False), size=20)
         TITLE                                    RUBRIQUE        DATE PUB.      VIS.
-------  ---------------------------------------  --------------  -----------  ------
4525701  ce_que_changera_la_nouvelle_prime_d_ac…  les-decodeurs   2014/11/19     7518
1610778  pour_les_agriculteurs_ressemer_sa_prop…  planete         2011/11/29     5002
1708654  christine_lagarde_non_plus_ne_paie_pas…  europe          2012/05/28      944
4463702  pas_si_facile_de_lutter_contre_l_evasi…  les-decodeurs   2014/07/28      776
4442319  au_fait_quelle_difference_entre_sunnit…  les-decodeurs   2014/06/20      749
3415091  ecole_primaire_et_secondaire             ecole-primair…  2013/05/22      727
4473771  fiscalite_ce_que_signifient_les_annonc…  les-decodeurs   2014/08/20      556
1331612  moi_claude_dilain_maire_de_clichy_sous…  societe         2010/04/10      549
4525745  areva_dans_la_tourmente                  economie        2014/11/19      547
1574837  le_cochon_de_gaza_une_farce_ni_casher_…  cinema          2011/09/20      522

Trois types de parcours

In [605]:
arts = [4442319, 1708654, 4467506, 1331612]
tmplot(arts, hits, content, \
       title="Visites quotidiennes entre le 3 janvier et le 4 mars 2015", \
       ylabel="Visites", \
       xlabel="Dates")
  • vieux articles référencés par des articles "chauds" et/ou mis à jour récemment
  • articles mis en valeur dans les blocs de recommandation pendant quelques jours/semaines
  • articles Evergreen, qui reviennent actuels de temps à autre

Un « evergreen indicator »

Si on a accès à la timeline d'un content, on peut élaborer une définition a posteriori d'evergreen.

Un article est evergreen sur une période donnée s'il:

  • a une médiane de visites élevée
  • n'a pas été publié pendant cette période

Evergreen indicator : un faux-evergreen

In [622]:
fake_eg = 1610778

info = content.loc[fake_eg]
tm = get_timeline(hits, fake_eg, 30)

f, axarr = plt.subplots(1,2, figsize=(17, 5))
f.suptitle('Un fake evergreen : {}… ({}, {})'.format(info.title[:50] , info.rubrique, info.published), fontsize=16, y=1.05)

tm.plot(ax=axarr[0], title="timeline sur 30 jours")
axarr[0].set_ylabel("visites")

tm_mean = tm.mean()
tm_median = np.median(tm.values)
axarr[1].hist(tm.values, bins=20)
axarr[1].set_title('Fréquences des visites')
axarr[1].set_xlabel('Visites')
axarr[1].axvline(tm_mean, c='b', label="Moyenne = {:.0f}".format(tm_mean))
axarr[1].axvline(tm_median, c='r', label="Médiane = {:.0f}".format(tm_median))
axarr[1].legend()
plt.show()
  • La moyenne sur le mois est élevée (506 visites)
  • La médiane indique que la plupart des jours, l'article n'a presque pas de vues

Pour les distributions biaisées, la médiane est une meilleure mesure de la tendance centrale que la moyenne.

Evergreen indicator : un vrai evergreen

In [625]:
aid = 4442319

info = content.loc[aid]
tm = get_timeline(hits, aid, 30)

f, axarr = plt.subplots(1,2, figsize=(17, 5))
f.suptitle('Un vrai evergreen : {}… ({}, {})'.format(info.title[:50] , info.rubrique, info.published), fontsize=16, y=1.05)

tm.plot(ax=axarr[0], title="Timeline sur 30 jours")
axarr[0].set_ylabel("visites")

tm_mean = tm.mean()
tm_median = np.median(tm.values)
axarr[1].hist(tm.values, bins=20)
axarr[1].set_title('Fréquences des visites')
axarr[1].set_xlabel('Visites')
axarr[1].axvline(tm_mean, c='b', label="Moyenne = {:.0f}".format(tm_mean))
axarr[1].axvline(tm_median, c='r', label="Médiane = {:.0f}".format(tm_median))
axarr[1].legend()
plt.show()
  • La médiane élevée indique bien que la baseline de visites de l'article est importante.
  • Plus de 50% des jours sur la période, l'article a plus de 850 visites.

Contenu Evergreen le mercredi 4 mars

In [503]:
def is_evergreen(info, views, refday, minday, threshold = 300):
    tm = get_timeline(views, info.id)
    val = tm[minday:refday].median()
    return val > threshold and info.published_dt < minday

daycontent['evergreen'] = daycontent.apply( \
    is_evergreen, refday = refday, minday = refday - dt.timedelta(days=30), \
    views = hits, threshold=200, axis=1)
In [626]:
list_content(daycontent[daycontent['evergreen']].sort(columns="hits", ascending=False), size=11)
         TITLE                                    RUBRIQUE        DATE PUB.      VIS.
-------  ---------------------------------------  --------------  -----------  ------
1708654  christine_lagarde_non_plus_ne_paie_pas…  europe          2012/05/28      944
4442319  au_fait_quelle_difference_entre_sunnit…  les-decodeurs   2014/06/20      749
4555180  de_charlie_a_dieudonne_jusqu_ou_va_la_…  les-decodeurs   2015/01/14      577
1717261  merah_aurait_decouvert_qu_il_etait_man…  societe         2012/06/12      421
4560905  une_nouvelle_etude_accable_la_cigarett…  sante           2015/01/22      403
4542278  la_carte_a_13_regions_definitivement_a…  politique       2014/12/17      309
4335033  trois_questions_sur_la_hausse_du_smic    societe         2013/12/16      288
4408809  tout_comprendre_sur_votre_fiche_de_pai…  les-decodeurs   2014/04/29      286
4567091  marine_le_pen_en_tete_en_2017_des_sond…  politique       2015/01/30      239
4554839  c_est_charlie_venez_vite_ils_sont_tous…  societe         2015/01/13      198
4353582  en_inde_une_femme_condamnee_a_un_viol_…  asie-pacifiqu…  2014/01/23      195

Critère : médiane de plus de 300 visites, sur un mois.

Cette liste comprend deux types d'articles : les vrais-de-vrai, et ceux qui ont du potentiel.

Evergreen : les vrais de vrai

In [627]:
aids = [4442319, 4353582]
tmplot(aids, hits, content, title="Deux articles vrais-de-vrai evergreen", ylabel="Visites", xlabel="Dates")
  • « vieux » articles (+ 6 mois)
  • une baseline de visites quotidiennes non négligeable
  • des pics quand l'actu s'y prête
  • reflètent les centres d'intérêt récurrents des lecteurs (no comments plz ...)

Pas encore evergreen, mais il y a du potentiel

In [628]:
aids = [4555180, 4560905]
tmplot(aids, hits, content, title="Deux articles ayant du potentiel evergreen", ylabel="Visites (log)", xlabel="Dates", lgy=True)
  • Gardent un trafic considérable et stable après le pic initial
  • Indiquent un intérêt des lecteurs pour le sujet sur lequel on peut capitaliser

Résumons tout ça

  • LeMonde.fr : une machine bien huilée pour couvrir (exclusivement?) l'actu
  • Il n'y a presque pas d'articles evergreen, mais on peut en identifier quelques uns
    • Il faudrait les mettre à jour de temps à autre.
  • On peut également identifier les articles presque-evergreen
    • Indiquent des sujets pertinents, à traiter
    • Avec un peu d'amour journalistique et de rérérencement, ils pourraient verdir

Nuances

  • Les articles ne sont peut-être pas le contenu le plus propice à du evergreen.
  • Les vidéos, les grands-formats, les portolios, les posts de blog s'y prêtent davantage
  • Il faut leur donner de la visibilité, tout en réservant nos vitrines aux « blockbusters »

Enjeux

Ou pourquoi les contenus intemporels sont importants pour un site d'actu

  • Augmentation du ROI sur certains contenus
  • Lectures récurrentes : l'internaute a une mémoire de poisson rouge
  • Acquisition d'audience
  • Meuilleure utilisation des efforts journalistiques, via la réutilisation des contenus

Case study: Refreshing the Evergreen

La suite

  • 1ère étape: rendre accessible la timeline des vues des contenus
  • Rendre visibles les contenus evergreen-ish et qui auraient besoin d'amour
  • Réfléchir à une stratégie de valorisation et de ré-utilisation des ces contenus

Il y a déjà de quoi faire un petit prototype non ?

In []: