Cas pratiques

Cas pratique - Analyse des ventes avec Window Functions SQL

Cas pratique SQL : analyse des ventes avec window functions (cumuls, LAG, RANK, moyenne mobile, parts de marché, évolution annuelle). Dataset réel de 62 833 ventes.

1. Introduction

Dans le précédent cas pratique, nous avons nettoyé un dataset de 63 000 ventes. Nous disposons désormais d’une table clean_sales de qualité, prête pour l’analyse.

Ce second cas pratique explore les fonctions de fenêtrage (window functions) de SQL : SUM() OVER, LAG(), RANK(), AVG() OVER avec fenêtre glissante. Ces fonctions permettent de réaliser des analyses avancées sans sous-requêtes complexes.

Objectifs pédagogiques

À l’issue de ce cas pratique, vous saurez :
- Calculer des cumuls et parts de marché avec SUM() OVER
- Comparer des valeurs entre lignes avec LAG() et LEAD()
- Classer des données avec RANK() et DENSE_RANK()
- Lisser des séries temporelles avec des moyennes mobiles
- Analyser des évolutions annuelles avec LAG(..., 12)

Aperçu des données nettoyées

Figure 1 : Aperçu de la table clean_sales après nettoyage.

2. Prérequis

Pour suivre ce cas pratique, vous avez besoin de :

  • SQLite et DB Browser for SQLite (gratuits)
  • Le dataset nettoyé clean_sales_data.csv ou la base sales_cleaning.db

Télécharger le dataset nettoyé clean_sales_data.csv (5.8 Mo)

Télécharger la base SQLite sales_cleaning.db (4.2 Mo)

3. Correction préalable : les dates au format ISO

Problème identifié

SQLite ne possède pas de type DATE natif. Les dates sont stockées au format MM-DD-YYYY et triées alphabétiquement, pas chronologiquement. Cela fausse toutes les analyses temporelles.

Erreur de tri des dates avant correction

Figure 2 : Avant correction – les dates ne sont pas triées chronologiquement.

Solution : Ajouter une colonne Sale_Date_ISO au format YYYY-MM-DD.

Ajout de la colonne date ISO

ALTER TABLE clean_sales ADD COLUMN Sale_Date_ISO TEXT;

UPDATE clean_sales 
SET Sale_Date_ISO = SUBSTR(Sale_Date_Clean, 7, 4) || '-' || 
                   SUBSTR(Sale_Date_Clean, 1, 2) || '-' || 
                   SUBSTR(Sale_Date_Clean, 4, 2)
WHERE Sale_Date_Clean LIKE '__-__-____';

Vérification de la conversion des dates

Figure 3 : Vérification – les dates sont maintenant au format ISO.

4. Correction préalable : les catégories de produits NULL

Deuxième problème identifié

Certains produits avaient une catégorie NULL, ce qui perturbait les classements. La solution : imputer la catégorie la plus fréquente pour chaque produit.

Imputation des catégories manquantes

-- 1. Table de correspondance (produit → catégorie la plus fréquente)
CREATE TABLE product_category_map AS
SELECT 
    Product_Name,
    Product_Category,
    COUNT(*) AS freq,
    ROW_NUMBER() OVER (PARTITION BY Product_Name ORDER BY COUNT(*) DESC) AS rn
FROM clean_sales
WHERE Product_Category IS NOT NULL
GROUP BY Product_Name, Product_Category;

-- 2. Garder seulement la catégorie la plus fréquente par produit
CREATE TABLE product_category_final AS
SELECT Product_Name, Product_Category
FROM product_category_map
WHERE rn = 1;

-- 3. Mise à jour des NULL
UPDATE clean_sales
SET Product_Category = (
    SELECT Product_Category 
    FROM product_category_final 
    WHERE product_category_final.Product_Name = clean_sales.Product_Name
)
WHERE Product_Category IS NULL;

Classement des produits après correction

Figure 4 : Après correction – plus de NULL, classement propre par catégorie.

5. Cumul des ventes par région

Objectif : suivre l’évolution cumulée du chiffre d’affaires pour chaque région.

Cumul des ventes par région

SELECT 
    COALESCE(Store_Location, 'Inconnu') AS Store_Location,
    Sale_Date_Clean,
    Total_Amount,
    SUM(Total_Amount) OVER (
        PARTITION BY COALESCE(Store_Location, 'Inconnu')
        ORDER BY Sale_Date_Clean
    ) AS cumul_ventes
FROM clean_sales
ORDER BY COALESCE(Store_Location, 'Inconnu'), Sale_Date_Clean
LIMIT 30;

Explication :

PARTITION BY Store_Location redémarre le cumul à chaque région.
ORDER BY Sale_Date_Clean cumule dans l’ordre chronologique.
COALESCE(..., 'Inconnu') regroupe les 641 lignes sans localisation sous une étiquette explicite.

Cumul des ventes par région

Figure 5 : Évolution cumulée des ventes – CHICAGO, IL en exemple.

6. Variation journalière (LAG)

Objectif : comparer le chiffre d’affaires d’un jour avec celui de la veille.

Variation par rapport à la veille

SELECT 
    Sale_Date_ISO,
    ROUND(SUM(Total_Amount), 2) AS ventes_journalieres,
    ROUND(LAG(SUM(Total_Amount), 1) OVER (ORDER BY Sale_Date_ISO), 2) AS ventes_veille,
    ROUND(SUM(Total_Amount) - LAG(SUM(Total_Amount), 1) OVER (ORDER BY Sale_Date_ISO), 2) AS variation
FROM clean_sales
WHERE Sale_Date_ISO IS NOT NULL
GROUP BY Sale_Date_ISO
ORDER BY Sale_Date_ISO
LIMIT 30;

Explication :

LAG(Total_Amount, 1) accède à la valeur de la ligne précédente (la veille).
La première ligne a ventes_veille = NULL (pas de jour précédent).
La colonne variation calcule la différence entre aujourd’hui et hier.

Variation journalière des ventes avec LAG

Figure 6 : Après correction des dates, la variation est maintenant chronologique.

7. Classement des produits par catégorie (RANK)

Objectif : identifier les produits les plus vendus dans chaque catégorie.

Classement des produits

SELECT 
    Product_Category,
    Product_Name,
    ROUND(SUM(Total_Amount), 2) AS total_ventes,
    RANK() OVER (PARTITION BY Product_Category ORDER BY SUM(Total_Amount) DESC) AS rang
FROM clean_sales
GROUP BY Product_Category, Product_Name
ORDER BY Product_Category, rang
LIMIT 30;

Explication :

RANK() OVER (PARTITION BY Product_Category ORDER BY SUM(Total_Amount) DESC)
- Classe les produits au sein de chaque catégorie (partie)
- Le meilleur produit a le rang 1
- En cas d’égalité, RANK saute des numéros (ex: 1,1,3)

Classement des produits par catégorie

Figure 7 : Top produits – dans chaque catégorie, les meilleures ventes sont identifiées.

8. Moyenne mobile 7 jours

Objectif : lisser les variations quotidiennes pour visualiser la tendance.

Moyenne mobile 7 jours

SELECT 
    Sale_Date_ISO,
    ROUND(Total_Amount, 2) AS Total_Amount,
    ROUND(AVG(Total_Amount) OVER (
        ORDER BY Sale_Date_ISO
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ), 2) AS moyenne_mobile_7j
FROM clean_sales
WHERE Sale_Date_ISO IS NOT NULL
ORDER BY Sale_Date_ISO
LIMIT 30;

Explication :

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW définit une fenêtre glissante de 7 lignes (les 6 précédentes + la ligne courante).
La moyenne mobile “lisse” les pics et creux pour révéler la tendance de fond.

Moyenne mobile 7 jours

Figure 8 : Moyenne mobile sur 7 jours – tendance plus lisible.

9. Part de marché par région

Objectif : calculer la contribution de chaque région au chiffre d’affaires total.

Parts de marché

SELECT 
    COALESCE(Store_Location, 'Inconnu') AS Store_Location,
    ROUND(SUM(Total_Amount), 2) AS ventes_region,
    ROUND(100.0 * SUM(Total_Amount) / SUM(SUM(Total_Amount)) OVER (), 2) AS part_pourcentage
FROM clean_sales
GROUP BY COALESCE(Store_Location, 'Inconnu')
ORDER BY ventes_region DESC;

Explication :

SUM(SUM(Total_Amount)) OVER () calcule le total général des ventes.
Le ratio SUM(Total_Amount) / total_général * 100 donne la part de marché.
L’absence de PARTITION BY applique le calcul à toute la table.

Parts de marché par région

Figure 9 : Parts de marché – 6 régions se partagent ~99% des ventes, 1% est non localisé.

17,25%

PHOENIX, AZ

16,56%

NEW YORK, NY

16,43%

PHILADELPHIA, PA

16,40%

HOUSTON, TX

10. Top 3 produits par catégorie

Objectif : extraire les 3 meilleurs produits de chaque catégorie.

Top 3 par catégorie (sous-requête)

SELECT * FROM (
    SELECT 
        Product_Category,
        Product_Name,
        ROUND(SUM(Total_Amount), 2) AS total_ventes,
        RANK() OVER (PARTITION BY Product_Category ORDER BY SUM(Total_Amount) DESC) AS rang
    FROM clean_sales
    GROUP BY Product_Category, Product_Name
) AS ranked
WHERE rang <= 3
ORDER BY Product_Category, rang;

Explication :

La sous-requête calcule le rang de chaque produit dans sa catégorie.
La requête externe filtre pour ne garder que les rangs 1, 2 et 3.
On obtient ainsi le “podium” des produits par catégorie.

Top 3 produits par catégorie

Figure 10 : Podium des produits – chaque catégorie a son top 3.

Catégorie1er produit2e produit3e produit
BEAUTYShampooToothpasteSoap
CLOTHINGT-shirtJeansSneakers
ELECTRONICSSmartwatchTabletSmartphone
GROCERIESCerealCoffeeEggs
HOME GOODSTowelsCurtainsLamp

11. Évolution mensuelle des ventes (LAG annuel)

Objectif : comparer les ventes d’un mois avec celles du même mois de l’année précédente.

LAG sur 12 mois avec CTE

WITH ventes_par_mois AS (
    SELECT 
        strftime('%Y-%m', Sale_Date_ISO) AS mois,
        ROUND(SUM(Total_Amount), 2) AS ventes_mois
    FROM clean_sales
    WHERE Sale_Date_ISO IS NOT NULL
    GROUP BY strftime('%Y-%m', Sale_Date_ISO)
)
SELECT 
    mois,
    ventes_mois,
    LAG(ventes_mois, 12) OVER (ORDER BY mois) AS ventes_annee_precedente,
    ROUND(100.0 * (ventes_mois - LAG(ventes_mois, 12) OVER (ORDER BY mois)) 
          / NULLIF(LAG(ventes_mois, 12) OVER (ORDER BY mois), 0), 2) AS evolution_pct
FROM ventes_par_mois
ORDER BY mois;

Explication :

Une CTE (Common Table Expression) pré-calcule les ventes par mois.
LAG(ventes_mois, 12) OVER (ORDER BY mois) récupère la valeur d’il y a 12 mois.
Les 12 premiers mois ont NULL (pas d’année précédente).
NULLIF évite la division par zéro.

Évolution mensuelle avec LAG annuel

Figure 11 : Comparaison année sur année – croissance soutenue jusqu’en mars 2026.

+140,6%

avril 2025 vs avril 2024

+67,2%

février 2026 vs février 2025

-84,2%

avril 2026 (partiel)

12. Conclusion

Ce cas pratique a démontré la puissance des fonctions de fenêtrage SQL pour l’analyse de données métier. En une vingtaine de requêtes, nous avons :

  • Calculé des cumuls et parts de marché avec SUM() OVER
  • Comparé les ventes jour/jour avec LAG()
  • Classé les produits par catégorie avec RANK()
  • Lissé les tendances avec une moyenne mobile sur 7 jours
  • Extraits les top produits par catégorie
  • Analysé l’évolution annuelle avec LAG(..., 12)

À retenir

  • SUM() OVER (PARTITION BY ... ORDER BY ...) : cumuls par groupe
  • LAG() / LEAD() : comparer avec lignes précédentes/suivantes
  • RANK() / DENSE_RANK() : classements avec ou sans sauts
  • AVG() OVER (ROWS BETWEEN N PRECEDING AND CURRENT ROW) : moyennes mobiles
  • SUM() OVER () : total général (sans PARTITION BY)
  • Les CTE (WITH) : indispensables pour les calculs en plusieurs étapes

Pour aller plus loin : Découvrez notre tutoriel complet sur les fonctions de fenêtrage SQL pour maîtriser toutes les window functions.

FAQ

Pourquoi avoir ajouté une colonne Sale_Date_ISO ?

SQLite ne possède pas de type DATE natif. Les dates stockées en texte sont triées alphabétiquement. MM-DD-YYYY mélange les années. La conversion en YYYY-MM-DD garantit un tri chronologique correct.

Quelle est la différence entre RANK() et DENSE_RANK() ?

RANK() saute des numéros après une égalité (ex: 1,1,3,4). DENSE_RANK() ne saute jamais (ex: 1,1,2,3). Pour un classement sans trous, utilisez DENSE_RANK().

Les mêmes requêtes fonctionnent-elles sur PostgreSQL ou MySQL ?

Globalement oui. Les window functions sont standard SQL. Seules quelques différences : strftime devient DATE_FORMAT (MySQL) ou TO_CHAR (PostgreSQL). GLOB est spécifique à SQLite.

Que signifie ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ?

Cela définit une fenêtre glissante de 7 lignes : les 6 lignes précédentes + la ligne courante. La moyenne est recalculée à chaque ligne, ce qui "lisse" les variations quotidiennes.

Pourquoi utiliser une CTE pour l'évolution mensuelle ?

SQL ne permet pas de référencer un alias (mois) dans la même clause OVER. La CTE pré-calcule les ventes mensuelles, puis on applique LAG sur cette table dérivée.

Faites parler vos données
Apprenez les méthodes et les outils pour extraire de la valeur stratégique : Data Science : Le guide complet des méthodes et outils.