Visualisation statistique#

Hide code cell source

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.ticker as ticker
import matplotlib.gridspec as gridspec
import seaborn as sns
from scipy import stats

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
rng = np.random.default_rng(42)

# Palette colorblind-safe (Okabe-Ito)
CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442",
              "#0072B2", "#D55E00", "#CC79A7", "#000000"]

Visualiser des données, c’est traduire des nombres abstraits en formes perçues par l’œil. Un bon graphique révèle une structure ; un mauvais graphique la cache ou en invente une. Ce chapitre couvre les principes qui distinguent les deux, les outils seaborn pour les mettre en œuvre, et les erreurs visuelles à éviter absolument.

Principes de Tufte#

Edward Tufte, dans The Visual Display of Quantitative Information (1983), a posé les fondements d’une éthique de la visualisation statistique. Ses principes restent la référence.

Data-ink ratio. Chaque trait d’encre devrait servir à représenter des données. Tout trait décoratif (grilles épaisses, ombres, dégradés, cadres 3D) dilue ce ratio. Le but est de maximiser le ratio données/encre.

Chartjunk. Les éléments non informatifs — hatching, moiré, logos, légendes inutiles — distraient et polluent. Tufte les appelle le chartjunk.

Mensonges visuels. Un axe tronqué qui commence à 97 % au lieu de 0 % pour exagérer une variation de 2 % est un mensonge visuel. L’aire représentée doit être proportionnelle aux données.

Hide code cell source

fig, axes = plt.subplots(2, 3, figsize=(16, 9))

# ---- Avant : mauvais graphique ----
# Axe tronqué qui exagère
ax = axes[0, 0]
categories = ["A", "B", "C", "D"]
valeurs = [97.2, 98.5, 98.1, 99.3]
bars = ax.bar(categories, valeurs, color=["#4C72B0", "#DD8452", "#55A868", "#C44E52"])
ax.set_ylim(96, 100)  # axe tronqué : la moindre différence paraît énorme
ax.set_title("MAUVAIS : axe tronqué (96–100%)", color="tomato", fontweight="bold")
ax.set_ylabel("Score (%)")
for bar, val in zip(bars, valeurs):
    ax.text(bar.get_x() + bar.get_width()/2, val + 0.05, f"{val}%", ha="center", fontsize=8)

# ---- Après : bon graphique ----
ax2 = axes[0, 1]
bars2 = ax2.bar(categories, valeurs, color=["#4C72B0", "#DD8452", "#55A868", "#C44E52"])
ax2.set_ylim(0, 105)  # axe complet
ax2.set_title("BON : axe depuis 0", color="seagreen", fontweight="bold")
ax2.set_ylabel("Score (%)")
for bar, val in zip(bars2, valeurs):
    ax2.text(bar.get_x() + bar.get_width()/2, val + 0.5, f"{val}%", ha="center", fontsize=8)

# ---- Variation relative, représentation honnête ----
ax3 = axes[0, 2]
var_rel = [v - np.mean(valeurs) for v in valeurs]
colors_var = ["tomato" if v < 0 else "seagreen" for v in var_rel]
ax3.bar(categories, var_rel, color=colors_var)
ax3.axhline(0, color="black", lw=1)
ax3.set_title("ALTERNATIF : variation par rapport à la moyenne", color="steelblue")
ax3.set_ylabel("Écart à la moyenne (%)")

# ---- Chartjunk vs propre ----
ax4 = axes[1, 0]
n_data = [45, 78, 62, 91, 55]
x_pos = np.arange(5)
ax4.bar(x_pos, n_data, color="steelblue", alpha=0.4, hatch="///")  # chartjunk
ax4.set_facecolor("#f0f0e8")  # fond inutile
ax4.spines["top"].set_visible(True)
ax4.spines["right"].set_visible(True)
ax4.grid(True, which="both", alpha=0.5, lw=1)  # grille épaisse
ax4.set_title("MAUVAIS : chartjunk, fond, hachures", color="tomato", fontweight="bold")
ax4.set_xticks(x_pos)
ax4.set_xticklabels([f"G{i+1}" for i in range(5)])

ax5 = axes[1, 1]
ax5.bar(x_pos, n_data, color="steelblue", alpha=0.8, width=0.6)
ax5.spines["top"].set_visible(False)
ax5.spines["right"].set_visible(False)
ax5.yaxis.grid(True, alpha=0.4)
ax5.set_axisbelow(True)
ax5.set_title("BON : épuré, data-ink ratio élevé", color="seagreen", fontweight="bold")
ax5.set_xticks(x_pos)
ax5.set_xticklabels([f"G{i+1}" for i in range(5)])

# ---- 3D inutile (simulé avec perspective) ----
ax6 = axes[1, 2]
from matplotlib.patches import FancyBboxPatch
parts = [35, 25, 20, 20]
labels = ["Partie A", "Partie B", "Partie C", "Partie D"]
colors_pie = CB_PALETTE[:4]
# Camembert classique (déjà problématique)
wedges, texts, autotexts = ax6.pie(parts, labels=labels, autopct="%1.0f%%",
                                    colors=colors_pie,
                                    startangle=90,
                                    pctdistance=0.75)
ax6.set_title("DISCUTABLE : camembert\n(difficile à comparer les tranches)", color="darkorange")

plt.suptitle("Principes de Tufte : avant / après", fontsize=13, fontweight="bold")
plt.tight_layout()
plt.show()
_images/28009f0fdf5c064f085e7b415089510a3699549049e7e4bb9dc40c91228221ef.png

Quand le camembert est-il acceptable ?

Le camembert fonctionne bien uniquement lorsqu’il y a 2 à 3 catégories, que l’on veut montrer qu’une partie représente environ la moitié ou un tiers du tout, et qu’une comparaison précise n’est pas requise. Pour comparer des valeurs proches, un bar chart horizontal est toujours plus lisible.

Choisir le bon graphique#

Le choix du graphique dépend de la nature des variables et de la question posée.

Hide code cell source

# Données pour les démonstrations
n = 400
groupes = rng.choice(["Groupe A", "Groupe B", "Groupe C"], size=n, p=[0.4, 0.35, 0.25])
valeurs_continues = np.where(groupes == "Groupe A",
                              rng.normal(50, 12, n),
                              np.where(groupes == "Groupe B",
                                       rng.normal(58, 10, n),
                                       rng.normal(45, 15, n)))
temps = pd.date_range("2023-01-01", periods=200, freq="D")
serie_temp = 15 + 10 * np.sin(np.linspace(0, 2*np.pi, 200)) + rng.normal(0, 2, 200)

df_demo = pd.DataFrame({"groupe": groupes, "valeur": valeurs_continues})
df_temps = pd.DataFrame({"date": temps, "temperature": serie_temp})

Hide code cell source

fig = plt.figure(figsize=(16, 12))
gs = gridspec.GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.35)

# 1. Variable continue : histogramme + KDE
ax1 = fig.add_subplot(gs[0, 0])
ax1.hist(valeurs_continues, bins=30, density=True, alpha=0.55, color="steelblue")
kde_x = np.linspace(valeurs_continues.min(), valeurs_continues.max(), 300)
ax1.plot(kde_x, stats.gaussian_kde(valeurs_continues)(kde_x), "r-", lw=2)
ax1.set_title("1 var. continue : histogramme + KDE")
ax1.set_xlabel("Valeur")
ax1.set_ylabel("Densité")
ax1.spines["top"].set_visible(False)
ax1.spines["right"].set_visible(False)

# 2. Variable continue × catégorielle : violin plot
ax2 = fig.add_subplot(gs[0, 1])
sns.violinplot(data=df_demo, x="groupe", y="valeur", hue="groupe",
               palette=CB_PALETTE[:3], legend=False, inner="box", ax=ax2)
ax2.set_title("Continue × catégorielle : violin plot")
ax2.spines["top"].set_visible(False)
ax2.spines["right"].set_visible(False)

# 3. Deux variables continues : scatter
ax3 = fig.add_subplot(gs[0, 2])
x_sc = rng.normal(0, 1, 200)
y_sc = 0.6 * x_sc + rng.normal(0, 0.8, 200)
ax3.scatter(x_sc, y_sc, alpha=0.5, s=20, color="steelblue")
m, b, r, _, _ = stats.linregress(x_sc, y_sc)
x_l = np.linspace(x_sc.min(), x_sc.max(), 100)
ax3.plot(x_l, m*x_l+b, "r-", lw=2, label=f"r={r:.2f}")
ax3.set_title("2 var. continues : scatter + régression")
ax3.legend(fontsize=9)
ax3.spines["top"].set_visible(False)
ax3.spines["right"].set_visible(False)

# 4. Série temporelle
ax4 = fig.add_subplot(gs[1, :2])
ax4.plot(df_temps["date"], df_temps["temperature"], color="steelblue", lw=1.5)
ax4.fill_between(df_temps["date"], df_temps["temperature"],
                 alpha=0.15, color="steelblue")
# Moyenne mobile
ma7 = df_temps["temperature"].rolling(7, center=True).mean()
ax4.plot(df_temps["date"], ma7, "r-", lw=2, label="Moyenne mobile 7j")
ax4.set_title("Série temporelle : ligne + bande")
ax4.set_ylabel("Température (°C)")
ax4.legend(fontsize=9)
ax4.spines["top"].set_visible(False)
ax4.spines["right"].set_visible(False)
ax4.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter("%b %Y"))
plt.setp(ax4.xaxis.get_majorticklabels(), rotation=25, ha="right")

# 5. Variable catégorielle : bar chart horizontal
ax5 = fig.add_subplot(gs[1, 2])
cats = pd.Series(groupes).value_counts().sort_values()
ax5.barh(cats.index, cats.values, color=CB_PALETTE[:3], height=0.55)
ax5.spines["top"].set_visible(False)
ax5.spines["right"].set_visible(False)
ax5.set_title("1 var. catégorielle : bar chart horizontal")
ax5.set_xlabel("Effectif")
for i, v in enumerate(cats.values):
    ax5.text(v + 1, i, str(v), va="center", fontsize=9)

# 6. Distribution cumulative (ECDF)
ax6 = fig.add_subplot(gs[2, 0])
for gname, color in zip(["Groupe A", "Groupe B", "Groupe C"], CB_PALETTE):
    sub = df_demo[df_demo["groupe"] == gname]["valeur"].sort_values()
    ecdf = np.arange(1, len(sub)+1) / len(sub)
    ax6.step(sub, ecdf, where="post", lw=2, label=gname, color=color)
ax6.set_title("ECDF comparatif")
ax6.set_xlabel("Valeur")
ax6.set_ylabel("Fréquence cumulée")
ax6.legend(fontsize=9)
ax6.spines["top"].set_visible(False)
ax6.spines["right"].set_visible(False)

# 7. Quantile-quantile plot
ax7 = fig.add_subplot(gs[2, 1])
(osm, osr), (slope, intercept, r_qq) = stats.probplot(valeurs_continues, dist="norm")
ax7.scatter(osm, osr, alpha=0.4, s=10, color="steelblue")
x_line = np.array([osm.min(), osm.max()])
ax7.plot(x_line, slope * x_line + intercept, "r-", lw=2)
ax7.set_title("QQ-plot (diagnostic normalité)")
ax7.set_xlabel("Quantiles théoriques")
ax7.set_ylabel("Quantiles observés")
ax7.spines["top"].set_visible(False)
ax7.spines["right"].set_visible(False)

# 8. Boxplot multi-groupes
ax8 = fig.add_subplot(gs[2, 2])
sns.boxplot(data=df_demo, x="groupe", y="valeur", hue="groupe",
            palette=CB_PALETTE[:3], legend=False, width=0.5, ax=ax8)
ax8.set_title("Boxplot comparatif")
ax8.spines["top"].set_visible(False)
ax8.spines["right"].set_visible(False)

plt.suptitle("Galerie : choisir le bon graphique selon la question posée",
             fontsize=13, fontweight="bold", y=1.01)
plt.show()
_images/26ceddd7262d7ffa4ffea1d8497f0358470f8dd47c60d1adb580a4e9f7adbbda.png

Intervalles de confiance visuels#

Une erreur fréquente est de confondre écart-type, erreur standard et intervalle de confiance dans les barres d’erreur.

  • Écart-type (\(\sigma\)) : dispersion des observations individuelles.

  • Erreur standard (\(\sigma / \sqrt{n}\)) : incertitude sur la moyenne estimée.

  • IC 95% : l’intervalle contient la vraie moyenne avec probabilité 95 % (sur des répétitions infinies).

Hide code cell source

groupes_uniq = ["Groupe A", "Groupe B", "Groupe C"]
stats_par_groupe = {}
for g in groupes_uniq:
    sub = df_demo[df_demo["groupe"] == g]["valeur"]
    n = len(sub)
    mu = sub.mean()
    sigma = sub.std()
    se = sigma / np.sqrt(n)
    t_crit = stats.t.ppf(0.975, df=n-1)
    stats_par_groupe[g] = {"n": n, "mu": mu, "sigma": sigma, "se": se,
                            "ic_low": mu - t_crit * se, "ic_high": mu + t_crit * se}

fig, axes = plt.subplots(1, 3, figsize=(14, 5), sharey=True)

titres = ["Barres = écart-type σ\n(dispersion des données)",
          "Barres = erreur standard σ/√n\n(incertitude sur la moyenne)",
          "Barres = IC 95%\n(interprétation correcte pour l'inférence)"]
for ax, (titre, key_low, key_high) in zip(axes, [
    (titres[0], "sigma", "sigma"),
    (titres[1], "se", "se"),
    (titres[2], None, None)
]):
    x_pos = np.arange(len(groupes_uniq))
    mus = [stats_par_groupe[g]["mu"] for g in groupes_uniq]
    if key_low == "sigma":
        errs = [stats_par_groupe[g]["sigma"] for g in groupes_uniq]
    elif key_low == "se":
        errs = [stats_par_groupe[g]["se"] for g in groupes_uniq]
    else:
        errs_low = [m - stats_par_groupe[g]["ic_low"] for m, g in zip(mus, groupes_uniq)]
        errs_high = [stats_par_groupe[g]["ic_high"] - m for m, g in zip(mus, groupes_uniq)]
        errs = [errs_low, errs_high]

    if isinstance(errs[0], list):
        ax.errorbar(x_pos, mus, yerr=errs, fmt="o", capsize=5,
                    color="steelblue", ecolor="tomato", elinewidth=2, capthick=2,
                    markersize=8)
    else:
        ax.errorbar(x_pos, mus, yerr=errs, fmt="o", capsize=5,
                    color="steelblue", ecolor="tomato", elinewidth=2, capthick=2,
                    markersize=8)

    ax.set_xticks(x_pos)
    ax.set_xticklabels(groupes_uniq, rotation=15)
    ax.set_title(titre, fontsize=8.5)
    ax.set_ylabel("Valeur")
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

plt.suptitle("Barres d'erreur : ce qu'elles représentent", fontsize=12, fontweight="bold")
plt.tight_layout()
plt.show()
_images/d6e086dfaee64705b3144b977af77ed6ae2aa85e3ffd64d7039af30c7a4095d9.png

Règle d’or pour les barres d’erreur

Indiquez toujours explicitement dans la légende ce que représentent vos barres d’erreur (écart-type, SE, IC 95 %). L’absence de cette information rend le graphique ambigu. Pour l’inférence statistique (comparaison de groupes), les IC 95 % sont les plus informatifs : deux IC qui se chevauchent légèrement peuvent correspondre à une différence statistiquement significative si les variances sont différentes.

Représentation de l’incertitude#

Au-delà des simples barres d’erreur, les distributions a posteriori, les fan charts et les simulations offrent des représentations plus riches de l’incertitude.

Hide code cell source

fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# --- Fan chart (prévisions avec intervalles croissants) ---
ax = axes[0]
t = np.arange(0, 30)
prevision = 50 + 0.8 * t
sigma_t = 2 + 0.4 * t  # incertitude croissante

ax.plot(t, prevision, "steelblue", lw=2, label="Prévision centrale")
niveaux = [(0.50, 0.9), (0.75, 0.5), (0.95, 0.25)]
for niveau, alpha in niveaux:
    z = stats.norm.ppf((1 + niveau) / 2)
    ax.fill_between(t, prevision - z * sigma_t, prevision + z * sigma_t,
                    alpha=alpha, color="steelblue", label=f"IC {int(niveau*100)}%")
ax.set_title("Fan chart — incertitude croissante")
ax.set_xlabel("Temps")
ax.set_ylabel("Valeur")
ax.legend(fontsize=8)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

# --- Simulations superposées ---
ax2 = axes[1]
t_sim = np.linspace(0, 10, 100)
for _ in range(80):
    a = rng.normal(1, 0.3)
    b = rng.normal(0.5, 0.2)
    y_sim = a * np.sin(b * t_sim) + rng.normal(0, 0.1, 100)
    ax2.plot(t_sim, y_sim, alpha=0.08, color="steelblue", lw=1)
# Simulation médiane
a_med, b_med = 1.0, 0.5
ax2.plot(t_sim, a_med * np.sin(b_med * t_sim), "r-", lw=2.5, label="Médiane")
ax2.set_title("Simulations superposées (faible alpha)")
ax2.set_xlabel("Temps")
ax2.set_ylabel("Signal")
ax2.legend()
ax2.spines["top"].set_visible(False)
ax2.spines["right"].set_visible(False)

# --- Distributions a posteriori (bootstrap visualisé) ---
ax3 = axes[2]
data_obs = rng.normal(52, 8, 30)
boots_mu = [rng.choice(data_obs, size=len(data_obs), replace=True).mean()
            for _ in range(2000)]
ax3.hist(boots_mu, bins=40, density=True, alpha=0.55, color="steelblue",
         label="Distribution bootstrap de la moyenne")
ic_low, ic_high = np.percentile(boots_mu, [2.5, 97.5])
ax3.axvline(np.mean(data_obs), color="tomato", lw=2, label=f"Moyenne obs. = {np.mean(data_obs):.1f}")
ax3.axvspan(ic_low, ic_high, alpha=0.2, color="tomato", label=f"IC 95% = [{ic_low:.1f}, {ic_high:.1f}]")
ax3.set_xlabel("Moyenne estimée")
ax3.set_ylabel("Densité")
ax3.set_title("Distribution bootstrap de l'estimateur")
ax3.legend(fontsize=7.5)
ax3.spines["top"].set_visible(False)
ax3.spines["right"].set_visible(False)

plt.suptitle("Représentations de l'incertitude", fontsize=12, fontweight="bold")
plt.tight_layout()
plt.show()
_images/bd5e03441f363e226a3ca388089dd51ceb54e6df5a8f7f1cdd7ec2e0f0addf2e.png

Seaborn : FacetGrid, catplot, displot#

Seaborn offre une grammaire des graphiques qui permet de décomposer facilement les visualisations selon des variables catégorielles.

Hide code cell source

# Données : qualité de vin (simulées à partir des statistiques connues)
n_vin = 500
acidite = rng.normal(7.5, 1.2, n_vin)
alcool = rng.normal(10.5, 1.1, n_vin)
qualite = np.round(np.clip(
    3.5 + 0.15 * alcool + 0.05 * (acidite - 7) + rng.normal(0, 0.8, n_vin),
    3, 8
)).astype(int)
type_vin = rng.choice(["Rouge", "Blanc"], p=[0.6, 0.4], size=n_vin)
region = rng.choice(["Bordeaux", "Bourgogne", "Loire"], size=n_vin)

df_vin = pd.DataFrame({
    "acidite": acidite,
    "alcool": alcool,
    "qualite": qualite,
    "type": type_vin,
    "region": region,
})

Hide code cell source

# FacetGrid : distribution de la qualité par région et type
g = sns.FacetGrid(df_vin, col="region", row="type", height=3, aspect=1.1,
                  palette=CB_PALETTE)
g.map(sns.histplot, "qualite", bins=range(3, 9), kde=True, color="steelblue")
g.set_axis_labels("Note de qualité", "Effectif")
g.set_titles(col_template="{col_name}", row_template="{row_name}")
g.figure.suptitle("Distribution de la qualité par région et type de vin",
                  y=1.02, fontsize=12)
plt.tight_layout()
plt.show()
_images/3f2386daa6153024b440588b5f5c2c8565bcaef7b261aca5670b3b7a10365678.png

Hide code cell source

fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# catplot simulation via axes
# Violin plot : alcool selon le type et la qualité ≥ 6
ax = axes[0, 0]
df_vin["bonne_qualite"] = (df_vin["qualite"] >= 6).map({True: "Oui", False: "Non"})
sns.violinplot(data=df_vin, x="type", y="alcool", hue="bonne_qualite",
               split=True, inner="box", palette={"Oui": "seagreen", "Non": "tomato"},
               ax=ax)
ax.set_title("Teneur en alcool selon le type\net la qualité (vert = ≥6)")
ax.set_xlabel("Type de vin")
ax.set_ylabel("Alcool (%)")
ax.legend(title="Qualité ≥ 6", fontsize=8)

# Scatter avec régression par type
ax2 = axes[0, 1]
for vtype, color in zip(["Rouge", "Blanc"], CB_PALETTE[:2]):
    sub = df_vin[df_vin["type"] == vtype]
    ax2.scatter(sub["acidite"], sub["alcool"], alpha=0.3, s=15, color=color, label=vtype)
    m, b, r, p, _ = stats.linregress(sub["acidite"], sub["alcool"])
    x_l = np.linspace(sub["acidite"].min(), sub["acidite"].max(), 100)
    ax2.plot(x_l, m*x_l+b, "-", color=color, lw=2)
    ax2.annotate(f"r={r:.2f}", xy=(x_l[-1], m*x_l[-1]+b+0.05), fontsize=8, color=color)
ax2.set_xlabel("Acidité (g/L)")
ax2.set_ylabel("Alcool (%)")
ax2.set_title("Acidité vs alcool par type")
ax2.legend()

# Barplot moyen par région avec IC
ax3 = axes[1, 0]
sns.barplot(data=df_vin, x="region", y="qualite", hue="type",
            palette=CB_PALETTE[:2], capsize=0.1, err_kws={"linewidth": 2},
            estimator="mean", errorbar=("ci", 95), ax=ax3)
ax3.set_title("Qualité moyenne par région et type\n(barres = IC 95%)")
ax3.set_ylabel("Qualité moyenne")
ax3.set_xlabel("")
ax3.legend(title="Type", fontsize=9)

# Heatmap corrélations
ax4 = axes[1, 1]
corr_vin = df_vin[["acidite", "alcool", "qualite"]].corr()
sns.heatmap(corr_vin, annot=True, fmt=".3f", cmap="RdBu_r", center=0,
            vmin=-1, vmax=1, ax=ax4, square=True, linewidths=0.5)
ax4.set_title("Corrélations inter-variables (vin)")

plt.suptitle("Seaborn : visualisations statistiques multi-variables", fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/394fb98aac987d4be34c1b10d45247fc2d4336f38de642780f6703bbd900a96a.png

Accessibilité : palettes daltonisme-friendly#

Environ 8 % des hommes et 0,5 % des femmes présentent un déficit de perception des couleurs. La deutéranopie (insensibilité au vert) et la protanopie (insensibilité au rouge) sont les formes les plus fréquentes.

Hide code cell source

def simuler_deuteranopie(rgb):
    """Simulation grossière de la vision deutéranope (sans vert)."""
    r, g, b = rgb[0], rgb[1], rgb[2]
    r_new = 0.625 * r + 0.375 * g + 0.0 * b
    g_new = 0.7 * r + 0.3 * g + 0.0 * b
    b_new = 0.0 * r + 0.3 * g + 0.7 * b
    return np.clip([r_new, g_new, b_new], 0, 1)

# Palettes à comparer
palettes_info = {
    "Mauvaise (rouge/vert)": ["#FF0000", "#00AA00", "#0000FF", "#FF8800"],
    "Okabe-Ito (colorblind)": ["#E69F00", "#56B4E9", "#009E73", "#CC79A7"],
    "Tableau colorblind": sns.color_palette("colorblind", 4).as_hex(),
    "Viridis (séquentielle)": [plt.cm.viridis(v) for v in [0.1, 0.4, 0.7, 0.9]],
}

fig, axes = plt.subplots(2, len(palettes_info), figsize=(16, 5))

for col, (nom, palette) in enumerate(palettes_info.items()):
    # Vision normale
    ax = axes[0, col]
    for i, c in enumerate(palette):
        if isinstance(c, str):
            rgb = plt.matplotlib.colors.to_rgb(c)
        else:
            rgb = c[:3]
        ax.add_patch(plt.Rectangle((i, 0), 1, 1, color=rgb))
    ax.set_xlim(0, len(palette))
    ax.set_ylim(0, 1)
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_title(nom, fontsize=8.5)
    if col == 0:
        ax.set_ylabel("Vision normale", fontsize=9)

    # Simulation deutéranopie
    ax2 = axes[1, col]
    for i, c in enumerate(palette):
        if isinstance(c, str):
            rgb = plt.matplotlib.colors.to_rgb(c)
        else:
            rgb = list(c[:3])
        rgb_sim = simuler_deuteranopie(rgb)
        ax2.add_patch(plt.Rectangle((i, 0), 1, 1, color=rgb_sim))
    ax2.set_xlim(0, len(palette))
    ax2.set_ylim(0, 1)
    ax2.set_xticks([])
    ax2.set_yticks([])
    if col == 0:
        ax2.set_ylabel("Deutéranopie (simul.)", fontsize=9)

plt.suptitle("Palettes de couleurs : vision normale vs deutéranopie simulée",
             fontsize=12, fontweight="bold")
plt.tight_layout()
plt.show()
_images/13adbd3165ab8e0005d7464b5d1c51b1bcbbd8364ad0832c952875bab9c0bbea.png

Hide code cell source

# Démonstration sur un graphique réel
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

groupes_4 = ["A", "B", "C", "D"]
valeurs_4 = [45, 72, 58, 81]

# Palette rouge/vert problématique
ax = axes[0]
ax.bar(groupes_4, valeurs_4, color=["#FF0000", "#00AA00", "#FF0000", "#00AA00"])
ax.set_title("Palette rouge/vert\n→ indistinguable en deutéranopie", color="tomato")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

# Palette Okabe-Ito
ax2 = axes[1]
ax2.bar(groupes_4, valeurs_4, color=CB_PALETTE[:4])
ax2.set_title("Palette Okabe-Ito (colorblind-safe)\n→ distinguable pour tous", color="seagreen")
ax2.spines["top"].set_visible(False)
ax2.spines["right"].set_visible(False)

# Ajouter formes en plus des couleurs (accessibilité maximale)
for bar, marker in zip(ax2.patches, ["o", "s", "^", "D"]):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 1, marker, ha="center", fontsize=14)

plt.suptitle("Accessibilité : couleurs + formes", fontsize=12)
plt.tight_layout()
plt.show()
_images/a826b15034887716f5be96bea6aef0a43773981ba412fb8c649acd5fa1aacbd9.png

Graphiques trompeurs vs corrects : galerie#

Hide code cell source

fig, axes = plt.subplots(2, 3, figsize=(16, 9))

# 1. Taille de police insuffisante
ax = axes[0, 0]
x = np.linspace(0, 10, 100)
ax.plot(x, np.sin(x), lw=2)
ax.set_title("Titre illisible", fontsize=5)  # trop petit
ax.set_xlabel("Variable explicative (unités)", fontsize=5)
ax.set_ylabel("Réponse (unités)", fontsize=5)
ax.tick_params(labelsize=5)
ax.annotate("MAUVAIS : textes trop petits", xy=(0.5, 0.5), xycoords="axes fraction",
            ha="center", fontsize=10, color="tomato",
            bbox=dict(boxstyle="round", fc="lightyellow"))

# 2. Proportionnel à l'aire, pas au rayon (erreur bulle)
ax2 = axes[0, 1]
valeurs_bulle = [10, 30, 60]
labels_b = ["A", "B", "C"]
# Erreur : rayon proportionnel à la valeur → aire × 4 plus grande
positions_x = [1, 2, 3]
for x_b, v, label in zip(positions_x, valeurs_bulle, labels_b):
    # MAUVAIS : rayon = valeur → aire = π·v²
    ax2.scatter(x_b, 1, s=v**2 * 3, color="tomato", alpha=0.5)
    ax2.text(x_b, 1, f"{label}\n({v})", ha="center", va="center", fontsize=8)
ax2.set_xlim(0, 4)
ax2.set_ylim(0.5, 1.5)
ax2.set_yticks([])
ax2.set_title("MAUVAIS : rayon ∝ valeur (devrait être aire ∝ valeur)", color="tomato", fontsize=8.5)

# 3. Même graphique corrigé
ax3 = axes[0, 2]
for x_b, v, label in zip(positions_x, valeurs_bulle, labels_b):
    # BON : aire = valeur → rayon = √(valeur/π)
    ax3.scatter(x_b, 1, s=v * 30, color="seagreen", alpha=0.5)
    ax3.text(x_b, 1, f"{label}\n({v})", ha="center", va="center", fontsize=8)
ax3.set_xlim(0, 4)
ax3.set_ylim(0.5, 1.5)
ax3.set_yticks([])
ax3.set_title("BON : aire ∝ valeur (rayon = √valeur)", color="seagreen", fontsize=8.5)

# 4. Axes incohérents sur un double axe
ax4 = axes[1, 0]
t_ax = np.arange(12)
ventes = [100, 120, 105, 130, 145, 160, 155, 170, 165, 180, 190, 200]
temperature_ax = [5, 6, 10, 14, 18, 23, 26, 25, 21, 15, 9, 5]
color1, color2 = "steelblue", "tomato"
ax4b = ax4.twinx()
line1, = ax4.plot(t_ax, ventes, color=color1, lw=2, marker="o", ms=4, label="Ventes")
line2, = ax4b.plot(t_ax, temperature_ax, color=color2, lw=2, marker="s", ms=4, label="Température")
ax4.set_ylabel("Ventes", color=color1)
ax4b.set_ylabel("Température (°C)", color=color2)
ax4.tick_params(axis="y", colors=color1)
ax4b.tick_params(axis="y", colors=color2)
ax4.set_title("Double axe : à utiliser avec précaution\n(peut simuler une corrélation)",
              fontsize=8.5)
ax4.legend(handles=[line1, line2], loc="upper left", fontsize=8)

# 5. Bonne représentation de la tendance temporelle
ax5 = axes[1, 1]
mois = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]
ax5.plot(t_ax, ventes, color="steelblue", lw=2, marker="o", ms=5)
ax5.fill_between(t_ax,
                 [v - 8 for v in ventes],
                 [v + 8 for v in ventes], alpha=0.2, color="steelblue")
ax5.set_xticks(t_ax)
ax5.set_xticklabels(mois)
ax5.set_ylabel("Ventes")
ax5.set_title("BON : une variable, un axe, IC visible", color="seagreen", fontsize=8.5)
ax5.spines["top"].set_visible(False)
ax5.spines["right"].set_visible(False)

# 6. Sparklines : maximum d'information, minimum d'encre
ax6 = axes[1, 2]
n_series = 5
offsets = np.arange(n_series) * 3
for i in range(n_series):
    y_spark = rng.normal(0, 1, 20).cumsum()
    y_norm = (y_spark - y_spark.min()) / (y_spark.max() - y_spark.min())
    ax6.plot(np.arange(20), y_norm + offsets[i], lw=1.2, color=CB_PALETTE[i])
    ax6.text(20.5, y_norm[-1] + offsets[i], f"Série {i+1}", va="center", fontsize=7)
ax6.set_xlim(0, 24)
ax6.axis("off")
ax6.set_title("Sparklines : tendances compactes", fontsize=8.5)

plt.suptitle("Visualisations : bonnes et mauvaises pratiques", fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/0b1cf36823c6bfab3c967c8118a885201e2ea89ee40d63562f5cea1cfc9b4f9d.png

Coordonnées parallèles et données multivariées#

Pour des données avec de nombreuses variables, les coordonnées parallèles permettent de visualiser les relations multivariées.

Hide code cell source

from pandas.plotting import parallel_coordinates

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Données iris
iris = sns.load_dataset("iris")

# Normaliser les colonnes
from sklearn.preprocessing import MinMaxScaler
iris_num = iris.drop("species", axis=1)
scaler = MinMaxScaler()
iris_norm = pd.DataFrame(scaler.fit_transform(iris_num),
                          columns=iris_num.columns)
iris_norm["species"] = iris["species"]

parallel_coordinates(iris_norm, "species",
                     color=CB_PALETTE[:3],
                     alpha=0.3, ax=axes[0])
axes[0].set_title("Coordonnées parallèles : iris (normalisé)")
axes[0].legend(fontsize=9)
axes[0].tick_params(axis="x", rotation=20)
axes[0].spines["top"].set_visible(False)
axes[0].spines["right"].set_visible(False)

# Heatmap de données normalisées (grandes dimensions)
n_obs = 50
n_vars = 8
data_hm = pd.DataFrame(rng.standard_normal((n_obs, n_vars)),
                        columns=[f"Var{i+1}" for i in range(n_vars)])
# Trier par première variable pour révéler des patterns
data_hm_sorted = data_hm.sort_values("Var1")
sns.heatmap(data_hm_sorted.T, cmap="RdBu_r", center=0,
            yticklabels=True, xticklabels=False,
            ax=axes[1], cbar_kws={"shrink": 0.8})
axes[1].set_title("Heatmap : données multivariées (50 obs × 8 vars)")
axes[1].set_xlabel("Observations (triées par Var1)")

plt.tight_layout()
plt.show()
_images/bf40decf54fe164c1862a24892e0b026f3c5cbe8009eef137b75046aa6a7937d.png

Principes d’accessibilité en visualisation

  • Couleurs : utiliser des palettes colorblind-safe (Okabe-Ito, colorblind de seaborn, viridis). Ne pas coder une information uniquement par la couleur — ajouter une forme, un motif ou un label.

  • Polices : taille minimale de 10pt pour les labels, 12pt pour les titres dans un article. Les titres doivent décrire la conclusion, pas seulement le contenu.

  • Contraste : ratio de contraste ≥ 4.5:1 pour le texte (norme WCAG AA).

  • Titre informatif : un titre du type « Les ventes ont augmenté de 40 % depuis janvier » est plus utile que « Évolution des ventes ».

  • Pas de 3D décoratif : les graphiques en relief ou en perspective distordent les perceptions de longueur et d’aire.

Une bonne visualisation statistique n’est pas un exercice artistique. C’est un acte de communication : elle doit permettre au lecteur d’extraire rapidement et fidèlement l’information pertinente, sans induire en erreur. Les principes de Tufte, les palettes accessibles et le choix rigoureux du type de graphique sont les outils de ce travail.