Plotly et Pandas : une combinaison puissante pour une visualisation de données efficace

La création de graphiques visuellement esthétiques et intuitifs peut être un défi, surtout sans formation en design. L'objectif est de communiquer des informations de manière claire et efficace, sans surcharger la capacité cognitive de l'audience.

Passer de Matplotlib à Seaborn, puis à Plotly ne résout pas tous les problèmes. La visualisation ne se résume pas à l'esthétique. Inspiré par "Storytelling with Data" de Cole Nussbaumer Knaflic, cet article explore comment créer des visualisations claires, élégantes et ciblées en utilisant Python, Pandas et Plotly.

Si vous cherchez des conseils pratiques spécifiques aux outils plutôt qu'une explication conceptuelle approfondie, cet article est pour vous. Les suggestions présentées ici sont indépendantes des outils, mais les exemples sont implémentés en Python.

Méthode de chaînage avec Pandas

Les utilisateurs expérimentés de Pandas connaissent probablement le concept de "chaînage" (method chaining). Cette approche rend le code plus lisible, plus facile à déboguer et plus adapté à un environnement de production. Voici un exemple simple :

(donnees_saison
 .rename(columns=lambda col: col.strip())
 .rename(columns=lambda col: re.sub('\W+|[!,*)@#%(&$_?.^]', '_', col))
 .pipe(lambda df: df.astype({colonne: 'int8' for colonne in (df.select_dtypes("integer").columns.tolist())}))
 .pipe(lambda df: df.astype({colonne: 'category' for colonne in (df.select_dtypes("object").columns.tolist()[:-1])}))
 .assign(date_match=lambda df: pd.to_datetime(df.date_match, infer_datetime_format=True))
 .assign(equipe_domicile=lambda df: np.where((df.equipe_domicile == "Arsenal"), "Les Gunners", df.equipe_domicile),
         equipe_exterieur=lambda df: np.where((df.equipe_exterieur == "Arsenal"), "Les Gunners", df.equipe_exterieur),
         mois=lambda df: df.date_match.dt.month_name())
 .query('equipe_domicile == "Les Gunners"')
)

Cette approche est excellente, mais saviez-vous que vous pouvez continuer à chaîner les méthodes pour créer des visualisations de base ? Par défaut, Pandas Plot utilise le backend Matplotlib pour cela. Voyons comment cela fonctionne et reproduisons certains exemples créés par Cole dans son livre.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.graph_objects as go
%matplotlib inline

pd.options.plotting.backend = 'plotly'

df = pd.DataFrame({"problemes": ["La puissance du moteur est inférieure aux attentes", 
                                "Les pneus font un bruit excessif en conduite",
                                "Le moteur fait un bruit anormal/excessif",
                                "Problèmes de matériau de siège",
                                "Bruit excessif du vent",
                                "Hésitation ou retard lors du changement de vitesse",
                                "Le système Bluetooth a une qualité sonore médiocre",
                                "Le système de direction/volant a trop de jeu",
                                "Le système Bluetooth est difficile à utiliser",
                                "Commandes audio/ divertissement/ navigation du siège avant"
                                 ],
                   "problemes par 1,000": [12.9, 12.3, 11.6, 11.6, 11.0, 10.3, 10.0, 8.8, 8.6, 8.2]}, 
                  index=list(range(0,10,1)))

Nous avons un DataFrame comme celui-ci :

(df
 .plot
 .barh()
)

C'est la méthode la plus rapide pour générer une visualisation de base. En chaînant directement l'attribut .plot et la méthode .line depuis le DataFrame, nous obtenons le graphique ci-dessus.

Si vous pensez que le graphique ci-dessus ne passe pas le test esthétique, retenez vos jugements. Il est, pour le moins, peu attrayant. Assaisonnons-le et faisons mieux. Le truc consiste à changer le backend de tracé de Pandas de Matplotlib à Plotly pour obtenir la magie qui s'ensuit.

pd.options.plotting.backend = 'plotly'

Vous vous demandez peut-être : "Pourquoi devrais-je le changer en Plotly ? Matplotlib ne peut pas faire la même chose ?" Eh bien, voici la différence.

Si nous utilisons le backend Matplotlib dans Pandas, il renvoie un objet Axes. Essayez de le vérifier avec la méthode type() intégrée. C'est génial car l'objet axes permet d'accéder à des méthodes pour modifier davantage le graphique. Consultez cette documentation pour connaître les méthodes possibles sur un objet Axes. Choisissons rapidement l'une d'entre elles pour illustrer.

(df
 .plot
 .barh()
 .set_xlabel("problemes par 1,000")
)

Nous avons réussi à définir l'étiquette de l'axe x sur "problèmes par 1,000", mais en le faisant, nous avons renvoyé un objet Text et perdu l'objet Axis précieux qui nous permettait d'accéder à des méthodes précieuses pour modifier davantage le graphique. Dommage !

Voici une autre méthode pour surmonter cette limitation,

(df
 .plot
 .barh(xlabel="Problèmes par 1,000", ylabel="Problèmes", title="Top 10 préoccupations de design")
)

Cependant, nous ne pouvons toujours pas effectuer de modifications étendues car l'implémentation de Pandas est très limitée en termes d'intégration.

D'autre part, Plotly ne renvoie pas d'objet Axes. Il renvoie un objet go.Figure. La différence ici est que les méthodes responsables de la mise à jour du graphique renvoient également un objet go.Figure, ce qui vous permet de continuer à chaîner les méthodes pour mettre à jour davantage le graphique. Essayons !

Par ailleurs, si vous vous demandez comment j'obtenu la combinaison de méthodes et de paramètres ci-dessous, elles sont toutes disponibles dans la documentation officielle. Voici quelques méthodes importantes pour vous aider à démarrer - .update_traces, .add_traces, .update_layout, .update_xaxes, .update_yaxes, .add_annotation, .update_annotations.

Graphique à barres horizontales

Définissons une palette de couleurs pour la visualisation ci-dessous.

GRIS1, GRIS2, GRIS3 = '#231F20', '#414040', '#555655'
GRIS4, GRIS5, GRIS6 = '#646369', '#76787B', '#828282'
GRIS7, GRIS8, GRIS9, GRIS10 = '#929497', '#A6A6A5', '#BFBEBE', '#FFFFFF'
BLEU1, BLEU2, BLEU3, BLEU4, BLEU5 = '#25436C', '#174A7E', '#4A81BF', '#94B2D7', '#94AFC5'
BLEU6, BLEU7 = '#92CDDD', '#2E869D'
ROUGE1, ROUGE2, ROUGE3 = '#B14D4A', '#C3514E', '#E6BAB7'
VERT1, VERT2 = '#0C8040', '#9ABB59'
ORANGE1, ORANGE2, ORANGE3 = '#F36721', '#F79747', '#FAC090'

palette_gris = [GRIS1, GRIS2, GRIS3, GRIS4, GRIS5, GRIS6, GRIS7, GRIS8, GRIS9, GRIS10]
palette_bleu = [BLEU1, BLEU2, BLEU3, BLEU4, BLEU5, BLEU6, BLEU7]
palette_rouge = [ROUGE1, ROUGE2, ROUGE3]
palette_vert = [VERT1, VERT2]
palette_orange = [ORANGE1, ORANGE2, ORANGE3]

sns.set_style("darkgrid")
sns.set_palette(palette_gris)
sns.palplot(sns.color_palette())

Ici, nous voulons mettre en évidence les problèmes égaux ou supérieurs à 10 %.

couleur = np.array(['rgb(255,255,255)']*df.shape[0])
couleur[df
      .set_index("problemes", drop=True)
      .iloc[::-1]
      ["problemes par 1,000"]>=10] = palette_rouge[0]
couleur[df
      .set_index("problemes", drop=True)
      .iloc[::-1]
      ["problemes par 1,000"]<10] = palette_gris[4]

Ensuite, nous créons directement le tracé à partir du DataFrame.

(df
 .set_index("problemes", drop=True)
 .iloc[::-1]
 .plot
 .barh()
 .update_traces(marker=dict(color=couleur.tolist()))
)

La mise à jour de la disposition produit le résultat suivant. Ici, nous spécifions le modèle, ajoutons un titre et des marges au graphique, et spécifions la taille de l'objet graphique. Nous commentons temporairement les annotations.

(df
 .set_index("problemes", drop=True)
 .iloc[::-1]
 .plot
 .barh()
 .update_traces(marker=dict(color=couleur.tolist()))
 .update_layout(template="plotly_white",
                title=dict(text="<b>Top 10 préoccupations de design</b> <br><sup><i>problèmes par 1,000</i></sup>", 
                           font_size=30,
                           font_color=palette_gris[4]),
                margin=dict(l=50,
                            r=50,
                            b=50,
                            t=100,
                            pad=20),
                width=1000, 
                height=800, 
                showlegend=False, 
                #annotations=annotations
               )
)

La mise à jour des propriétés des axes x et y produit le résultat suivant.

(df
 .set_index("problemes", drop=True)
 .iloc[::-1]
 .plot
 .barh()
 .update_traces(marker=dict(color=couleur.tolist()))
 .update_layout(template="plotly_white",
                title=dict(text="<b>Top 10 préoccupations de design</b> <br><sup><i>problèmes par 1,000</i></sup>", 
                           font_size=30,
                           font_color=palette_gris[4]),
                margin=dict(l=50,
                            r=50,
                            b=50,
                            t=100,
                            pad=20),
                width=1000, 
                height=800, 
                showlegend=False, 
                #annotations=annotations
               )
 .update_xaxes(title_standoff=10,
               showgrid=False,
               visible=False,
               tickfont=dict(
                        family='Arial',
                        size=16,
                        color=palette_gris[4],),
               title="")
 .update_yaxes(title_standoff=10,
               tickfont=dict(
                        family='Arial',
                        size=16,
                        color=palette_gris[4],),
               title="")
)

Enfin et surtout, nous allons ajouter quelques annotations au graphique. Ici, nous avons quelques annotations - ajouter des étiquettes de données au graphique à barres horizontales et une note de bas de page. Faisons-le ensemble. Tout d'abord, nous définissons les annotations dans une cellule séparée.

annotations = []

y_s = np.round(df["problemes par 1,000"], decimals=2)

# Ajout des étiquettes de données
for yd, xd in zip(y_s, df.problemes):
    # étiquetage de la barre
    annotations.append(dict(xref='x1', 
                            yref='y1',
                            y=xd, x=yd - 1,
                            text=str(yd) + '%',
                            font=dict(family='Arial', size=16,
                                      color=palette_gris[-1]),
                            showarrow=False))
    
# Ajout des annotations de source
annotations.append(dict(xref='paper', 
                        yref='paper',
                        x=-0.72, 
                        y=-0.050,
                        text='Source: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco'
                             '<br>laboris nisi ut aliquip ex ea commodo consequat.',
                        font=dict(family='Arial', size=10, color=palette_gris[4]),
                        showarrow=False,
                        align='left'))

(df
 .set_index("problemes", drop=True)
 .iloc[::-1]
 .plot
 .barh()
 .update_traces(marker=dict(color=couleur.tolist()))
 .update_layout(template="plotly_white",
                title=dict(text="<b>Top 10 préoccupations de design</b> <br><sup><i>problèmes par 1,000</i></sup>", 
                           font_size=30,
                           font_color=palette_gris[4]),
                margin=dict(l=50,
                            r=50,
                            b=50,
                            t=100,
                            pad=20),
                width=1000, 
                height=800, 
                showlegend=False, 
                annotations=annotations
               )
 .update_xaxes(title_standoff=10,
               showgrid=False,
               visible=False,
               tickfont=dict(
                        family='Arial',
                        size=16,
                        color=palette_gris[4],),
               title="")
 .update_yaxes(title_standoff=10,
               tickfont=dict(
                        family='Arial',
                        size=16,
                        color=palette_gris[4],),
               title="")
)

Par rapport à la version par défaut initiale, n'est-ce pas un meilleur graphique ? Continuons à explorer un autre graphique populaire - le graphique linéaire.

Notez que l'exemple ci-dessous est plus complexe que l'exemple précédent. Néanmoins, l'idée reste la même.

Graphique linéaire

Jetons un rapide coup d'œil au graphique Matplotlib par défaut avec le backend de tracé.

pd.options.plotting.backend = 'matplotlib'

df = pd.DataFrame({"Reçus": [160,184,241,149,180,161,132,202,160,139,149,177],
                   "Traites":[160,184,237,148,181,150,123,156,126,104,124,140]},
                  index=['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jui', 'Juil', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'])

(df
 .plot
 .line()
);

Changeons le back end de tracé de Pandas pour Plotly !

pd.options.plotting.backend = 'plotly'

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
)

Après avoir chengé le backend de tracé de Pandas pour Plotly, le code ci-dessus donne le résultat suivant. Ici, nous traçons d'abord uniquement la série Reçus.

Mettons à jour les propriétés de ligne en chaînant davantage les méthodes ci-dessus. Ici, nous modifions la couleur, la largeur et plaçons des marqueurs aux points de données.

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
 .update_traces(go.Scatter(mode='lines+markers+text', 
                           line={"color": palette_gris[4], "width":4},
                           marker=dict(size=12)),)
)

Ajoutons la serie Traitees au graphique !

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
 .update_traces(go.Scatter(mode='lines+markers+text', 
                           line={"color": palette_gris[4], "width":4},
                           marker=dict(size=12)),)
 .add_traces(go.Scatter(x=df.index, #Ajout colonne Traitees
                        y=df.Traitees, 
                        mode="lines+markers+text", 
                        line={"color": palette_bleu[0], "width":4},
                        marker=dict(size=12)))
)

Ajoutons une ligne verticale au niveau de l'index Mai pour indiquer le point où les deux lignes commencent à diverger.

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
 .update_traces(go.Scatter(mode='lines+markers+text', 
                           line={"color": palette_gris[4], "width":4},
                           marker=dict(size=12)),)
 .add_traces(go.Scatter(x=df.index, #Ajout colonne Traitees
                        y=df.Traitees, 
                        mode="lines+markers+text", 
                        line={"color": palette_bleu[0], "width":4},
                        marker=dict(size=12)))
 .add_traces(go.Scatter(x=["Mai", "Mai"], #Ajout ligne verticale
                        y=[0,230], 
                        fill="toself", 
                        mode="lines", 
                        line_width=0.5, 
                        line_color= palette_gris[4]))
)

Ensuite, mettons à jour la disposition globale en changeant l'arrière-plan en blanc et en ajoutant un titre, des marges et d'autres éléments. Pour les annotations, nous les laisons temporairement commentées.

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
 .update_traces(go.Scatter(mode='lines+markers+text', 
                           line={"color": palette_gris[4], "width":4},
                           marker=dict(size=12)),)
 .add_traces(go.Scatter(x=df.index, #Ajout colonne Traitees
                        y=df.Traitees, 
                        mode="lines+markers+text", 
                        line={"color": palette_bleu[0], "width":4},
                        marker=dict(size=12)))
 .add_traces(go.Scatter(x=["Mai", "Mai"], #Ajout ligne verticale
                        y=[0,230], 
                        fill="toself", 
                        mode="lines", 
                        line_width=0.5, 
                        line_color= palette_gris[4]))
 .update_layout(template="plotly_white",
                title=dict(text="<b>Veuillez approuver le recrutement de 2 ETP</b> <br><sup>pour remplacer ceux qui ont quitté au cours de la dernière année</sup> <br>Volume des tickets au fil du temps <br><br><br>", 
                           font_size=30,),
                margin=dict(l=50,
                            r=50,
                            b=100,
                            t=200,),
                width=900, 
                height=700, 
                yaxis_range=[0, 300], 
                showlegend=False, 
                #annotations=annotations_droite,
                )
)

Ensuite, nous allons effectuer des mises à jour sur les axes x et y.

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
 .update_traces(go.Scatter(mode='lines+markers+text', 
                           line={"color": palette_gris[4], "width":4},
                           marker=dict(size=12)),)
 .add_traces(go.Scatter(x=df.index, #Ajout colonne Traitees
                        y=df.Traitees, 
                        mode="lines+markers+text", 
                        line={"color": palette_bleu[0], "width":4},
                        marker=dict(size=12)))
 .add_traces(go.Scatter(x=["Mai", "Mai"], #Ajout ligne verticale
                        y=[0,230], 
                        fill="toself", 
                        mode="lines", 
                        line_width=0.5, 
                        line_color= palette_gris[4]))
 .update_layout(template="plotly_white",
                title=dict(text="<b>Veuillez approuver le recrutement de 2 ETP</b> <br><sup>pour remplacer ceux qui ont quitté au cours de la dernière année</sup> <br>Volume des tickets au fil du temps <br><br><br>", 
                           font_size=30,),
                margin=dict(l=50,
                            r=50,
                            b=100,
                            t=200,),
                width=900, 
                height=700, 
                yaxis_range=[0, 300], 
                showlegend=False, 
                #annotations=annotations_droite,
                )
 .update_xaxes(dict(range=[0, 12],
                    showline=True,
                    showgrid=False,
                    linecolor=palette_gris[4],
                    linewidth=2,
                    ticks='',
                    tickfont=dict(
                        family='Arial',
                        size=13,
                        color=palette_gris[4],
                    ),
 ))
 .update_yaxes(dict(showline=True,
                    showticklabels=True,
                    showgrid=False,
                    ticks='outside',
                    linecolor=palette_gris[4],
                    linewidth=2,
                    tickfont=dict(
                        family='Arial',
                        size=13,
                        color=palette_gris[4],
                    ),
                    title_text="Nombre de tickets"
 ))
)

Enfin et surtout, nous allons ajouter quelques annotations au graphique. Ici, nous avons quelques annotations - ajouter des étiquettes au graphique linéaire (Reçus, Traitees), ainsi que des étiquettes aux points de dispersion, ce qui peut être un peu complexe. Faisons-le ensemble. Tout d'abord, nous définissons les annotations dans une cellule séparée.

donnees_y = df.to_numpy()
couleurs = [palette_gris[3], palette_bleu[0]]
etiquettes = df.columns.to_list()

annotations_droite = []

# Ajout des étiquettes aux lignes
for y_trace, etiquette, couleur in zip(donnees_y[-1], etiquettes, couleurs):
    annotations_droite.append(dict(xref='paper', 
                                  x=0.95, 
                                  y=y_trace,
                                  xanchor='left', 
                                  yanchor='middle',
                                  text=etiquette,
                                  font=dict(family='Arial',size=16,couleur=couleur),
                                  showarrow=False))

# Ajout des étiquettes aux points de dispersion
annotations_dispersion = []

y_reçus = [chaque for chaque in df.Reçus]
y_traitees  = [float(chaque) for chaque in df.Traitees]
x_index = [chaque for chaque in df.index]
y_r = np.round(y_reçus)
y_t = np.rint(y_traitees)

for yrn, ytn, xd in zip(y_r[-5:], y_t[-5:], x_index[-5:]):
    annotations_dispersion.append(dict(xref='x2 domain', 
                                    yref='y2 domain', 
                                    y=yrn, 
                                    x=xd, 
                                    text='{:,}'.format(yrn),
                                    font=dict(family='Arial',size=16,color=palette_gris[4]),
                                    showarrow=False,
                                    xanchor='center', 
                                    yanchor='bottom',
                                    ))
    
    annotations_dispersion.append(dict(xref='x2 domain', 
                                    yref='y2 domain', 
                                    y=ytn, 
                                    x=xd, 
                                    text='{:,}'.format(ytn),
                                    font=dict(family='Arial',size=16,color=palette_bleu[0]),
                                    showarrow=False,
                                    xanchor='center', 
                                    yanchor='top',
                                    ))

Après avoir défini les annotations, nous n'avons qu'à placer la variable d'annotation dans la méthode de chaînage comme suit.

(df
 .plot(x=df.index, 
       y=df.Reçus,
       labels=dict(index="", value="Nombre de tickets"),)
 .update_traces(go.Scatter(mode='lines+markers+text', 
                           line={"color": palette_gris[4], "width":4},
                           marker=dict(size=12)),)
 .add_traces(go.Scatter(x=df.index, #Ajout colonne Traitees
                        y=df.Traitees, 
                        mode="lines+markers+text", 
                        line={"color": palette_bleu[0], "width":4},
                        marker=dict(size=12)))
 .add_traces(go.Scatter(x=["Mai", "Mai"], #Ajout ligne verticale
                        y=[0,230], 
                        fill="toself", 
                        mode="lines", 
                        line_width=0.5, 
                        line_color= palette_gris[4]))
 .update_layout(template="plotly_white",
                title=dict(text="<b>Veuillez approuver le recrutement de 2 ETP</b> <br><sup>pour remplacer ceux qui ont quitté au cours de la dernière année</sup> <br>Volume des tickets au fil du temps <br><br><br>", 
                           font_size=30,),
                margin=dict(l=50,
                            r=50,
                            b=100,
                            t=200,),
                width=900, 
                height=700, 
                yaxis_range=[0, 300], 
                showlegend=False, 
                annotations=annotations_droite,
                )
 .update_layout(annotations=annotations_dispersion * 2)
 .update_xaxes(dict(range=[0, 12],
                    showline=True,
                    showgrid=False,
                    linecolor=palette_gris[4],
                    linewidth=2,
                    ticks='',
                    tickfont=dict(
                        family='Arial',
                        size=13,
                        color=palette_gris[4],
                    ),
 ))
 .update_yaxes(dict(showline=True,
                    showticklabels=True,
                    showgrid=False,
                    ticks='outside',
                    linecolor=palette_gris[4],
                    linewidth=2,
                    tickfont=dict(
                        family='Arial',
                        size=13,
                        color=palette_gris[4],
                    ),
                    title_text="Nombre de tickets"
 ))
 .add_annotation(dict(text="<b>2 employés ont quitté en Mai.</b> Nous avons presque suivi le volume entrant <br>au cours des deux mois suivants, mais nous avons pris du retard avec l'augmentation en Août <br>et n'avons pas réussi à rattraper depuis.",
                      font_size=18,
                      align="left",
                      x=7.5,
                      y=265,
                      showarrow=False))
 .add_annotation(dict(xref='paper', 
                        yref='paper',
                        x=0.5, 
                        y=-0.15,
                        text='Source: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco'
                             '<br>laboris nisi ut aliquip ex ea commodo consequat.',
                        font=dict(family='Arial',
                                  size=10,
                                  color='rgb(150,150,150)'),
                        showarrow=False,
                        align='left'))
 .update_annotations(yshift=0)
 .show()
)

Étiquettes: visualisation-de-données plotly Pandas Python Data-Science

Publié le 26 juillet à 16h47