D5 – Mini-projet : une série temporelle¶
Python numérique – devoir n° 5 – à rendre pour le dimanche 18 octobre 2026, 23 h 59 (la séance 6 est le mardi 20)
Bienvenue dans votre cinquième devoir ! Nous allons y mettre en œuvre les notions vues en séance 5 : to_datetime, DatetimeIndex, la sélection par dates, resample, rolling, df.plot et les figures à plusieurs sous-graphiques avec matplotlib. C'est aussi un mini-projet : la dernière partie est ouverte, et c'est vous qui choisissez la question à laquelle répondre.
Durée indicative : 1 h 30.
{admonition}
:class: important
* Ce notebook reste dans le dossier `D5/` de votre dépôt `pe-homework` (celui que le zip du sujet a créé en se dézippant), avec ses figures à côté, puis il doit être **commité et poussé** (`git add`, `git commit`, `git push`) avant la deadline. Attention, c'est la **date du commit** qui fait foi, pas celle du push ; le nom du fichier, lui, n'a pas d'importance (`sujet.ipynb` convient très bien). Le dossier `data/` livré avec le sujet reste lui aussi dans `D5/` : `git add D5` embarque tout le dossier, et c'est très bien ainsi (il ne pèse que quelques kilo-octets).
* Juste avant de rendre, **pensez à ré-exécuter toutes les cellules** via *Kernel → Restart Kernel and Run All Cells* : toutes doivent s'exécuter dans l'ordre et sans erreur. En effet, un notebook qui plante à mi-chemin sera corrigé jusqu'à l'erreur…
* Le notebook lit les données dans `data/comptage-velos.csv` (chemin relatif) ; la figure `serie.png` est quant à elle écrite dans le dossier du notebook.
* Les cellules `# votre code` sont à compléter ; vous pouvez ajouter autant de cellules que vous le souhaitez. Les cellules de test (`assert`) sont là pour vous aider : si elles passent sans erreur, c'est bon signe. Ne les modifiez pas !
* Répondez aux questions "en français" dans une cellule *Markdown*, en deux ou trois phrases. Dans ce devoir, la rédaction compte autant que le code.
{admonition}
:class: note
* La documentation de `pandas` / `matplotlib`, les notebooks du cours, vos notes.
* Les assistants IA sont tolérés, avec la règle habituelle : **vous devez être capable d'expliquer chaque ligne que vous rendez**, et je pourrai vous demander de le faire à l'oral en séance. Le devoir est individuel.
* En revanche, pas de boucle `for` sur les lignes de la série : tout se fait avec les opérations `pandas`.
{admonition}
:class: warning
`data/comptage-velos.csv` contient le nombre de vélos comptés chaque jour par un compteur automatique installé sur une piste cyclable parisienne, du 1er janvier 2023 au 31 décembre 2024 : une colonne `date` (texte `AAAA-MM-JJ`) et une colonne `comptage`, séparées par `;`.
Attention, **ces données sont simulées** : je les ai produites avec le script `data/generate.py`, en fixant la graine afin que tout le monde travaille sur les mêmes valeurs. Vous pouvez le lire, mais seulement *après* avoir fait le devoir ! Elles ont cependant l'allure de vraies données de comptage, défauts compris : le fichier n'est pas trié, certaines lignes sont en double, certains jours n'ont pas de mesure, et le capteur a eu au moins deux ratés. Ainsi, comme dans la vraie vie, vous allez d'abord nettoyer, puis analyser.
Barème¶
| Exercice | Contenu | Points |
|---|---|---|
| 1 | Chargement, index temporel, diagnostic et nettoyage | 6 |
| 2 | Rééchantillonnage (resample) et lissage (rolling) |
4 |
| 3 | Profils hebdomadaire et mensuel, 2023 vs 2024 | 4 |
| 4 | Figure à deux sous-graphiques serie.png |
3 |
| 5 | Question ouverte : une question, une figure, trois lignes de conclusion | 3 |
| Total | 20 | |
| Bonus | Jour le plus creux, croissance annuelle | +2 |
Inspiré des exercices de https://numerique-exos.info-mines.paris.
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
Exercice 1 – Charger, indexer, nettoyer (6 pts)¶
1.a Lisez le fichier dans une DataFrame df et affichez df.info(). Quel est le type de la colonne date ? Pourquoi n'est-ce pas satisfaisant ? Le fichier est-il trié par date ?
# SOLUTION
df = pd.read_csv("data/comptage-velos.csv", sep=";")
df.info()
print("trié :", df["date"].is_monotonic_increasing)
df.head()
<class 'pandas.DataFrame'> RangeIndex: 734 entries, 0 to 733 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 date 734 non-null str 1 comptage 726 non-null float64 dtypes: float64(1), str(1) memory usage: 11.6 KB trié : False
| date | comptage | |
|---|---|---|
| 0 | 2023-01-01 | 304.0 |
| 1 | 2023-01-02 | 1658.0 |
| 2 | 2023-01-03 | 1450.0 |
| 3 | 2023-01-04 | 1902.0 |
| 4 | 2023-01-05 | 1715.0 |
assert df.shape == (734, 2)
assert list(df.columns) == ["date", "comptage"]
print("ok")
ok
1.b Construisez maintenant la Series brut : les comptages indexés par la date, avec un vrai DatetimeIndex, triée par date et en flottants.
# SOLUTION
df["date"] = pd.to_datetime(df["date"])
brut = df.sort_values("date").set_index("date")["comptage"].astype(float)
brut.head()
date 2023-01-01 304.0 2023-01-02 1658.0 2023-01-03 1450.0 2023-01-04 1902.0 2023-01-05 1715.0 Name: comptage, dtype: float64
assert isinstance(brut, pd.Series)
assert isinstance(brut.index, pd.DatetimeIndex)
assert brut.index.is_monotonic_increasing
assert len(brut) == 734
assert brut.index[0] == pd.Timestamp("2023-01-01") and brut.index[-1] == pd.Timestamp("2024-12-31")
print("ok")
ok
{admonition}
:class: note
`serie.duplicated()` — ou `index.duplicated()` — renvoie un tableau de booléens de même longueur, avec `True` sur les valeurs *déjà rencontrées plus haut* : la première occurrence vaut `False`, les suivantes `True`. Ainsi, sur `[3, 1, 3, 3]`, on obtient `[False, False, True, True]`. Combiné à `~`, il permet donc de ne garder que la première occurrence de chaque valeur.
1.c Place au diagnostic. Répondez d'abord par du code, puis résumez vos observations dans une cellule Markdown :
- combien de dates apparaissent en double ? lesquelles ?
- combien de valeurs manquantes ? quels jours ?
- observez
brut.describe(): le minimum et le maximum vous semblent-ils plausibles pour un compteur vélo ? Affichez les jours dont le comptage est nul, et ceux dont le comptage dépasse 3 fois la médiane.
# SOLUTION
doublons = brut.index.duplicated()
print("doublons :", doublons.sum(), list(brut.index[doublons].strftime("%Y-%m-%d")))
print("manquants :", brut.isna().sum(), list(brut.index[brut.isna()].strftime("%Y-%m-%d")))
print(brut.describe().round(0))
mediane = brut.median()
print("nuls :"); print(brut[brut == 0])
print("> 3 × médiane :"); print(brut[brut > 3 * mediane])
doublons : 3 ['2023-05-25', '2023-12-21', '2024-02-27'] manquants : 8 ['2023-02-23', '2023-06-25', '2023-12-01', '2024-01-09', '2024-02-08', '2024-03-13', '2024-11-17', '2024-11-22'] count 726.0 mean 2455.0 std 1646.0 min 0.0 25% 1770.0 50% 2301.0 75% 3090.0 max 39660.0 Name: comptage, dtype: float64 nuls : date 2024-06-27 0.0 Name: comptage, dtype: float64 > 3 × médiane : date 2024-07-04 39660.0 Name: comptage, dtype: float64
assert brut.index.duplicated().sum() == 3
assert brut.isna().sum() == 8
assert (brut == 0).sum() == 1
assert (brut > 3 * brut.median()).sum() == 1
print("ok")
ok
{admonition}
3 doublons (2023-05-25, 2023-12-21, 2024-02-27), 8 jours vides, un 0 le 2024-06-27 (panne) et 39 660 le 2024-07-04 (≈ 10 × la normale). Le maximum "normal" est inférieur à 5 000.
{admonition}
:class: note
`serie.interpolate()` remplace chaque `NaN` par une valeur déduite de ses voisins non manquants, par interpolation linéaire par défaut : sur `[10, NaN, NaN, 40]`, on obtient `[10, 20, 30, 40]`. C'est l'alternative à `fillna` quand les données ont un ordre naturel, comme ici le temps.
1.d Passons au nettoyage. Construisez la série propre velos, en trois temps :
- supprimez les doublons d'index en ne gardant que la première occurrence (
duplicated()) ; - remplacez les deux valeurs aberrantes (comptage nul ou supérieur à 3 fois la médiane) par
NaN; - remplissez tous les
NaN(les 8 jours vides + les 2 aberrants) par interpolation linéaire.
Vérifiez enfin que velos a exactement une valeur par jour du 1er janvier 2023 au 31 décembre 2024 (soit 731 jours), et plus aucun NaN.
# SOLUTION
velos = brut[~brut.index.duplicated()].copy()
aberrant = (velos == 0) | (velos > 3 * velos.median())
velos[aberrant] = np.nan
print("NaN avant interpolation :", velos.isna().sum())
velos = velos.interpolate()
print(len(velos), "jours, NaN :", velos.isna().sum(), ", min :", velos.min(), ", max :", velos.max())
NaN avant interpolation : 10 731 jours, NaN : 0 , min : 304.0 , max : 4764.0
assert len(velos) == 731
assert velos.isna().sum() == 0
assert not velos.index.duplicated().any()
assert (velos.index == pd.date_range("2023-01-01", "2024-12-31", freq="D")).all()
assert velos.min() > 100 and velos.max() < 6000
assert abs(velos.loc["2024-07-04"] - (velos.loc["2024-07-03"] + velos.loc["2024-07-05"]) / 2) < 1
print("ok")
ok
Exercice 2 – Rééchantillonner et lisser (4 pts)¶
2.a Calculez :
hebdo: la moyenne par semaine ;mensuel: la moyenne par mois (l'alias de fréquence des mois est"ME", month end, depuis pandas 2.2 ;"M"si votre pandas est plus ancien) ;annuel: le total par année.
Combien de lignes ont hebdo et mensuel ? Pourquoi hebdo n'a-t-il pas exactement 731 / 7 lignes ?
# SOLUTION
hebdo = velos.resample("W").mean()
mensuel = velos.resample("ME").mean()
annuel = velos.resample("YE").sum()
print(len(hebdo), len(mensuel), len(annuel))
annuel
106 24 2
date 2023-12-31 834384.0 2024-12-31 924366.5 Freq: YE-DEC, Name: comptage, dtype: float64
assert len(hebdo) == 106 and len(mensuel) == 24 and len(annuel) == 2
assert abs(mensuel.sum() * 30.4 - velos.sum()) / velos.sum() < 0.05
assert annuel.iloc[1] > annuel.iloc[0]
print("ok")
ok
{admonition}
731 jours = 104 semaines + 3 jours ; les semaines de `resample("W")` se terminent le dimanche, et la première (1er janvier 2023, un dimanche) comme la dernière sont incomplètes : 106 lignes.
2.b Calculez lisse, la moyenne glissante centrée sur 7 jours. Combien de NaN contient-elle, et où ? Pourquoi 7 jours est-il un bon choix de fenêtre pour cette série ? Enfin, tracez velos (trait fin, transparent) et lisse sur le même graphique, avec la méthode .plot() des Series.
# SOLUTION
lisse = velos.rolling(7, center=True).mean()
print("NaN :", lisse.isna().sum(), "->", list(lisse.index[lisse.isna()].strftime("%Y-%m-%d")))
ax = velos.plot(figsize=(11, 4), lw=0.6, alpha=0.5, label="journalier")
lisse.plot(ax=ax, lw=1.5, label="moyenne glissante 7 j")
ax.set_ylabel("vélos / jour"); ax.set_xlabel("")
ax.legend(); ax.set_title("Comptage journalier et lissage")
plt.show()
NaN : 6 -> ['2023-01-01', '2023-01-02', '2023-01-03', '2024-12-29', '2024-12-30', '2024-12-31']
assert len(lisse) == 731
assert lisse.isna().sum() == 6
assert abs(lisse.loc["2023-06-15"] - velos.loc["2023-06-12":"2023-06-18"].mean()) < 1e-9
print("ok")
ok
{admonition}
6 `NaN` : les 3 premiers et 3 derniers jours (la fenêtre centrée déborde). 7 jours = une semaine entière : chaque fenêtre contient exactement un week-end, ce qui gomme le cycle hebdomadaire sans déformer la saisonnalité.
Exercice 3 – Profils (4 pts)¶
3.a Commençons par le profil hebdomadaire : calculez profil_semaine, la moyenne des comptages pour chaque jour de la semaine, avec 0 = lundi … 6 = dimanche. Remplacez ensuite l'index 0..6 par les noms des jours ("lun", "mar", … "dim"). De combien (en %) le dimanche est-il plus bas que la moyenne des jours de semaine (lundi à vendredi) ?
# SOLUTION
profil_semaine = velos.groupby(velos.index.dayofweek).mean()
profil_semaine.index = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]
semaine = profil_semaine.iloc[:5].mean()
print(profil_semaine.round(0))
print(f"dimanche : {100 * (profil_semaine['dim'] / semaine - 1):.0f} % par rapport à lundi-vendredi")
lun 2694.0 mar 2869.0 mer 2878.0 jeu 2782.0 ven 2595.0 sam 1640.0 dim 1386.0 Name: comptage, dtype: float64 dimanche : -50 % par rapport à lundi-vendredi
assert profil_semaine.shape == (7,)
assert list(profil_semaine.index) == ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]
assert profil_semaine["sam"] < 0.75 * profil_semaine["lun"]
assert profil_semaine["dim"] < profil_semaine["sam"]
print("ok")
ok
{admonition}
:class: note
`serie.idxmax()` renvoie l'**étiquette** de la plus grande valeur (ici un numéro de mois), alors que `serie.max()` renvoie la valeur elle-même ; `idxmin` fait de même pour la plus petite. Sur une série indexée par des dates, on récupère donc directement le `Timestamp` du maximum.
3.b Intéressons-nous maintenant à la saisonnalité : calculez profil_mois, la moyenne pour chaque mois de l'année (1 à 12), les deux années confondues. Quel est le mois le plus chargé ? Et que se passe-t-il en août ?
# SOLUTION
profil_mois = velos.groupby(velos.index.month).mean()
print(profil_mois.round(0))
print("mois le plus chargé :", profil_mois.idxmax())
date 1 1548.0 2 1845.0 3 2144.0 4 2585.0 5 3013.0 6 3313.0 7 3285.0 8 2536.0 9 2694.0 10 2309.0 11 1924.0 12 1658.0 Name: comptage, dtype: float64 mois le plus chargé : 6
assert profil_mois.shape == (12,) and list(profil_mois.index) == list(range(1, 13))
assert profil_mois.idxmax() in (6, 7)
assert profil_mois[8] < profil_mois[7] and profil_mois[8] < profil_mois[9]
assert profil_mois.idxmin() in (1, 12)
print("ok")
ok
3.c Opposons enfin 2023 à 2024 : avec la sélection par chaîne de date, calculez la moyenne journalière de chaque année, puis la croissance en % de 2023 à 2024 (variable croissance). Posez-vous ensuite la même question mois par mois : construisez la table par_mois de forme (12, 2) (lignes = mois, colonnes = années) — unstack peut aider — et repérez le mois où la progression a été la plus forte.
# SOLUTION
m2023 = velos.loc["2023"].mean()
m2024 = velos.loc["2024"].mean()
croissance = 100 * (m2024 / m2023 - 1)
print(f"2023 : {m2023:.0f}/jour, 2024 : {m2024:.0f}/jour, croissance : {croissance:.1f} %")
par_mois = velos.groupby([velos.index.year, velos.index.month]).mean().unstack(0)
progression = 100 * (par_mois[2024] / par_mois[2023] - 1)
print("progression la plus forte : mois", progression.idxmax(), f"({progression.max():.1f} %)")
par_mois.round(0)
2023 : 2286/jour, 2024 : 2526/jour, croissance : 10.5 % progression la plus forte : mois 4 (16.0 %)
| date | 2023 | 2024 |
|---|---|---|
| date | ||
| 1 | 1452.0 | 1644.0 |
| 2 | 1755.0 | 1932.0 |
| 3 | 2054.0 | 2235.0 |
| 4 | 2393.0 | 2777.0 |
| 5 | 2850.0 | 3176.0 |
| 6 | 3223.0 | 3403.0 |
| 7 | 3092.0 | 3479.0 |
| 8 | 2454.0 | 2617.0 |
| 9 | 2537.0 | 2850.0 |
| 10 | 2232.0 | 2387.0 |
| 11 | 1816.0 | 2032.0 |
| 12 | 1549.0 | 1767.0 |
assert 7 < croissance < 14
assert par_mois.shape == (12, 2)
assert list(par_mois.columns) == [2023, 2024]
assert (par_mois[2024] > par_mois[2023]).sum() >= 10
print("ok")
ok
Exercice 4 – Figure à deux sous-graphiques (3 pts)¶
Construisez une figure fig, (ax_haut, ax_bas) = plt.subplots(2, 1, figsize=(11, 8)) :
- en haut : la série journalière
velos(trait fin, transparent) et la série lisséelisse(trait plus épais), avec une légende, un titre et un nom d'axe des ordonnées ; - en bas : le profil hebdomadaire
profil_semaineen barres, avec un titre et un nom d'axe des ordonnées.
Terminez par fig.tight_layout() et enregistrez dans serie.png (fig.savefig).
# SOLUTION
fig, (ax_haut, ax_bas) = plt.subplots(2, 1, figsize=(11, 8))
velos.plot(ax=ax_haut, lw=0.6, alpha=0.5, label="journalier")
lisse.plot(ax=ax_haut, lw=1.8, label="moyenne glissante 7 jours")
ax_haut.set_title("Compteur vélo : comptage journalier 2023-2024")
ax_haut.set_ylabel("vélos / jour"); ax_haut.set_xlabel("")
ax_haut.legend()
profil_semaine.plot.bar(ax=ax_bas, rot=0, color="tab:green")
ax_bas.set_title("Profil hebdomadaire (moyenne par jour de la semaine)")
ax_bas.set_ylabel("vélos / jour")
fig.tight_layout()
fig.savefig("serie.png", dpi=120)
plt.show()
assert os.path.exists("serie.png")
print("ok")
ok
Exercice 5 – Question ouverte (3 pts)¶
C'est ici que vous prenez la main ! Choisissez une question sur cette série, puis répondez-y avec une figure (au moins une courbe ou un diagramme, légendée et titrée) et trois lignes de conclusion en Markdown. Voici quelques idées, mais une question à vous est la bienvenue :
- le creux d'août a-t-il la même ampleur en 2023 et en 2024 ?
- l'écart week-end / semaine est-il le même en été et en hiver ?
- quelle semaine de l'année est la plus chargée ? la plus creuse ? est-ce la même les deux années ?
- la croissance entre 2023 et 2024 est-elle uniforme sur l'année, ou concentrée sur certains mois ?
- que se passe-t-il autour des jours fériés que vous connaissez ?
Côté barème, je compte un point pour la pertinence de la méthode, un point pour une figure lisible et correcte, et un point pour une conclusion chiffrée et honnête.
# SOLUTION
# Exemple : la croissance 2023 -> 2024 est-elle uniforme sur l'année ?
ax = par_mois.plot.bar(figsize=(10, 4), rot=0)
ax.set_title("Moyenne journalière par mois : 2023 vs 2024")
ax.set_xlabel("mois"); ax.set_ylabel("vélos / jour")
ax.legend(title="année")
plt.show()
ax = progression.plot.bar(figsize=(10, 3), rot=0, color="tab:orange")
ax.axhline(croissance, color="k", ls="--", label=f"croissance annuelle {croissance:.1f} %")
ax.set_title("Progression 2024 / 2023, mois par mois (%)")
ax.set_xlabel("mois"); ax.legend()
plt.show()
{admonition}
Exemple de conclusion attendue : « La croissance annuelle est d'environ 11 %, mais elle varie de mois en mois (de quelques % à plus de 15 %) : avec une seule année de recul et un bruit journalier de l'ordre de 8 %, les écarts entre mois ne sont pas tous significatifs ». On valorise une conclusion qui cite des chiffres et reconnaît les limites (série simulée, 2 ans seulement).
Bonus (+2)¶
Bo.1 Quel est le jour le plus creux de toute la série ? Et le plus chargé ? Pour chacun, donnez le jour de la semaine (.day_name()) et proposez une explication.
{admonition}
:class: note
`serie.pct_change()` renvoie la variation *relative* d'une valeur à la suivante : sur `[100, 110, 99]`, elle donne `[NaN, 0.10, -0.10]`. La première case vaut `NaN` faute de valeur précédente, et le résultat est une proportion, à multiplier par 100 pour l'exprimer en pourcentage.
Bo.2 Calculez la croissance annuelle en % à partir des totaux annuels annuel. Pourquoi ce chiffre est-il légèrement différent de croissance (exercice 3.c) ?
# SOLUTION
creux, pic = velos.idxmin(), velos.idxmax()
print("plus creux :", creux.date(), creux.day_name(), velos.min())
print("plus chargé :", pic.date(), pic.day_name(), velos.max())
print("croissance des totaux annuels :", (100 * annuel.pct_change()).round(2).iloc[-1], "%")
print("croissance des moyennes journalières :", round(croissance, 2), "%")
plus creux : 2023-01-01 Sunday 304.0 plus chargé : 2024-07-18 Thursday 4764.0 croissance des totaux annuels : 10.78 % croissance des moyennes journalières : 10.48 %
{admonition}
Le jour le plus creux est le dimanche 1er janvier 2023 (jour férié + dimanche + plein hiver) ; le plus chargé, un jour de semaine de juillet 2024. Les totaux annuels comparent 366 jours à 365, d'où ≈ 0,3 point de différence avec la comparaison des moyennes journalières.
Bon courage à tous ! Pour toute question sur le sujet, vous pouvez me contacter par email : raphael@imagine-app.fr.
Au plaisir de vous lire,
votre responsable de cours, Raphael Prasquier (sujet conçu par Aurélien Noce)