Cas pratique · SQL · Window Functions

Cas pratique : Analyse des ventes avec les Window Functions SQL

Cumuls, variations journalières, classements, moyennes mobiles, parts de marché et évolution annuelle – découvrez comment analyser un dataset de 62 833 ventes avec les fonctions de fenêtrage SQL.

Niveau : intermédiaire | Temps de lecture : 18 min | Outil : SQLite + DB Browser | Dataset : 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égorie 1er produit 2e produit 3e produit
BEAUTY Shampoo Toothpaste Soap
CLOTHING T-shirt Jeans Sneakers
ELECTRONICS Smartwatch Tablet Smartphone
GROCERIES Cereal Coffee Eggs
HOME GOODS Towels Curtains Lamp

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.

13. FAQ — Analyse avec Window Functions

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.

 

Recevez la veille IA & Data qui compte vraiment

 

    Analyses claires, outils concrets et tendances IA sans bruit.     Rejoignez les lecteurs de IANA Data.