Project : NYC Yellow Taxi Demand Analysis (2022–2024)
Bouzouita Hayette - Data Analyst
Step 1 : Data Import
1.1 – Libraries import¶
import pandas as pd
import numpy as np
from sqlalchemy import create_engine
1.2 – Database connection¶
engine = create_engine(
"postgresql://postgres:postgres@localhost:5432/taxi"
)
EN / The connection is established directly to the PostgreSQL database hosting the curated data mart.
FR / La connexion est établie directement vers la base PostgreSQL hébergeant le mart de données préparé lors de la phase de data engineering.
1.3 – Load mart table¶
query = """
SELECT *
FROM mart.mart_demand_hourly;
"""
df = pd.read_sql(query, engine)
1.4 – First inspection¶
df.head()
| hour_ts | pu_location_id | trips | avg_trip_distance | avg_total_amount | pct_cash | pct_card | |
|---|---|---|---|---|---|---|---|
| 0 | 2022-01-26 09:00:00 | 87 | 21 | 4.330952 | 23.917619 | 19.047619 | 71.428571 |
| 1 | 2022-01-26 13:00:00 | 89 | 1 | 5.200000 | 27.000000 | 0.000000 | 100.000000 |
| 2 | 2022-01-27 06:00:00 | 93 | 1 | 0.210000 | 174.360000 | 0.000000 | 100.000000 |
| 3 | 2022-01-26 07:00:00 | 42 | 8 | 2.378750 | 14.601250 | 62.500000 | 37.500000 |
| 4 | 2022-01-24 16:00:00 | 50 | 42 | 2.923333 | 18.084750 | 30.952381 | 59.523810 |
EN / Payment percentages do not always sum to 100%, as some trips correspond to other payment types (e.g., no charge, dispute, unknown).
FR / Les pourcentages de paiement ne totalisent pas toujours 100 %, car certaines courses correspondent à d’autres types de paiement (gratuit, litige, inconnu)
df.shape
(2819948, 7)
EN / The dataset contains approximately 2.8 million rows, each representing an hourly taxi demand observation for a given pickup zone.
FR / Le jeu de données contient environ 2,8 millions de lignes, chacune représentant une observation de la demande horaire de taxis pour une zone de prise en charge donnée.
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 2819948 entries, 0 to 2819947 Data columns (total 7 columns): # Column Dtype --- ------ ----- 0 hour_ts datetime64[ns] 1 pu_location_id int64 2 trips int64 3 avg_trip_distance float64 4 avg_total_amount float64 5 pct_cash float64 6 pct_card float64 dtypes: datetime64[ns](1), float64(4), int64(2) memory usage: 150.6 MB
1.5 – Data scope verification¶
df['hour_ts'].min(), df['hour_ts'].max()
(Timestamp('2022-01-01 00:00:00'), Timestamp('2024-12-31 23:00:00'))
EN / The data covers the period from 2022 to 2024, ensuring consistency with the project scope.
FR / Les données couvrent la période 2022–2024, conformément au périmètre du projet.
1.6 – Enrich data with taxi zone information¶
EN / Enrich the analytical dataset with human-readable taxi zone information to enable meaningful business analysis.
FR / Enrichir le jeu de données analytique avec des informations de zones de taxi lisibles afin de faciliter l’analyse métier.
zones = pd.read_sql("""
SELECT
location_id AS pu_location_id,
borough,
zone
FROM curated.dim_taxi_zone;
""", engine)
zones.head()
| pu_location_id | borough | zone | |
|---|---|---|---|
| 0 | 1 | EWR | Newark Airport |
| 1 | 2 | Queens | Jamaica Bay |
| 2 | 3 | Bronx | Allerton/Pelham Gardens |
| 3 | 4 | Manhattan | Alphabet City |
| 4 | 5 | Staten Island | Arden Heights |
EN / Taxi zones are described at different spatial levels: boroughs provide a macro-level view of the city, while zones offer a finer-grained spatial resolution.
FR / Les zones de taxi sont décrites à différents niveaux spatiaux : les boroughs offrent une vision macroscopique de la ville, tandis que les zones permettent une analyse plus fine.
df = df.merge(
zones,
on="pu_location_id",
how="left"
)
df.head()
| hour_ts | pu_location_id | trips | avg_trip_distance | avg_total_amount | pct_cash | pct_card | borough | zone | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 2022-01-26 09:00:00 | 87 | 21 | 4.330952 | 23.917619 | 19.047619 | 71.428571 | Manhattan | Financial District North |
| 1 | 2022-01-26 13:00:00 | 89 | 1 | 5.200000 | 27.000000 | 0.000000 | 100.000000 | Brooklyn | Flatbush/Ditmas Park |
| 2 | 2022-01-27 06:00:00 | 93 | 1 | 0.210000 | 174.360000 | 0.000000 | 100.000000 | Queens | Flushing Meadows-Corona Park |
| 3 | 2022-01-26 07:00:00 | 42 | 8 | 2.378750 | 14.601250 | 62.500000 | 37.500000 | Manhattan | Central Harlem North |
| 4 | 2022-01-24 16:00:00 | 50 | 42 | 2.923333 | 18.084750 | 30.952381 | 59.523810 | Manhattan | Clinton West |
df['borough'].isna().mean()
0.0
EN / At this stage, the dataset has been successfully imported and inspected.
The data is aggregated at an hourly and spatial level and is ready for analytical preprocessing and exploratory analysis.
FR / À ce stade, le jeu de données a été importé et inspecté avec succès.
Les données sont agrégées à un niveau horaire et spatial et sont prêtes pour le prétraitement analytique et l’analyse exploratoire.
Step 2 : Data Cleaning & Preprocessing (Analytical level)
Objective :¶
Ensure data quality and consistency at the analytical level before exploratory analysis and modeling.
Objectif :¶
Garantir la qualité et la cohérence des données au niveau analytique avant l’analyse exploratoire et la modélisation.
2.1 – Missing values (NA check)¶
df.isna().mean().sort_values(ascending=False)
avg_total_amount 0.000077 hour_ts 0.000000 pu_location_id 0.000000 trips 0.000000 avg_trip_distance 0.000000 pct_cash 0.000000 pct_card 0.000000 borough 0.000000 zone 0.000000 dtype: float64
EN / Given the extremely low proportion of missing values and the aggregated nature of the variable, avg_total_amount was left unchanged to avoid introducing artificial bias into the data.
FR / Étant donné la proportion extrêmement faible de valeurs manquantes et la nature agrégée de la variable, avg_total_amount a été conservée telle quelle afin d’éviter l’introduction d’un biais artificiel dans les données.
2.2 Target Variable Validation (trips)¶
df['trips'].describe()
count 2.819948e+06 mean 4.224735e+01 std 7.519024e+01 min 1.000000e+00 25% 1.000000e+00 50% 5.000000e+00 75% 5.000000e+01 max 1.239000e+03 Name: trips, dtype: float64
import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(8,4))
sns.boxplot(x=df['trips'],color='gold')
plt.title("Distribution of hourly taxi trips per zone")
plt.show()
EN / Extreme values were retained as they reflect genuine high-demand situations rather than data quality issues. Extreme values were considered structural as they are statistically consistent, operationally plausible, and recurrent across specific zones and peak hours.
FR / Les valeurs extrêmes ont été conservées car elles reflètent des situations réelles de forte demande et non des problèmes de qualité des données. Les valeurs extrêmes ont été considérées comme structurelles car elles sont statistiquement cohérentes, plausibles d’un point de vue métier et récurrentes sur certaines zones et périodes de pointe.
df.sort_values('trips', ascending=False).head(10)
| hour_ts | pu_location_id | trips | avg_trip_distance | avg_total_amount | pct_cash | pct_card | borough | zone | pct_sum | hour | weekday | is_weekend | month | year | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2639147 | 2024-11-03 01:00:00 | 79 | 1239 | 2.428515 | 22.308139 | 5.649718 | 84.907183 | Manhattan | East Village | 90.556901 | 1 | 6 | True | 11 | 2024 |
| 737986 | 2022-11-06 01:00:00 | 79 | 1115 | 2.931973 | 18.220752 | 6.816143 | 89.955157 | Manhattan | East Village | 96.771300 | 1 | 6 | True | 11 | 2022 |
| 1615645 | 2023-11-05 01:00:00 | 79 | 1106 | 2.291646 | 23.086694 | 6.871609 | 81.826401 | Manhattan | East Village | 88.698011 | 1 | 6 | True | 11 | 2023 |
| 2639183 | 2024-11-03 01:00:00 | 148 | 879 | 2.713675 | 23.967356 | 4.323094 | 84.414107 | Manhattan | Lower East Side | 88.737201 | 1 | 6 | True | 11 | 2024 |
| 2639232 | 2024-11-03 01:00:00 | 249 | 862 | 2.336462 | 22.369906 | 6.844548 | 79.930394 | Manhattan | West Village | 86.774942 | 1 | 6 | True | 11 | 2024 |
| 2037237 | 2024-04-14 01:00:00 | 79 | 849 | 2.554723 | 22.068698 | 4.240283 | 64.428740 | Manhattan | East Village | 68.669022 | 1 | 6 | True | 4 | 2024 |
| 1896413 | 2024-02-25 01:00:00 | 79 | 846 | 2.239421 | 21.546734 | 5.437352 | 65.602837 | Manhattan | East Village | 71.040189 | 1 | 6 | True | 2 | 2024 |
| 1615710 | 2023-11-05 01:00:00 | 249 | 837 | 2.295006 | 23.515884 | 4.181601 | 85.185185 | Manhattan | West Village | 89.366786 | 1 | 6 | True | 11 | 2023 |
| 2780088 | 2024-12-18 18:00:00 | 237 | 830 | 1.469518 | 24.069842 | 8.795181 | 82.409639 | Manhattan | Upper East Side South | 91.204819 | 18 | 2 | False | 12 | 2024 |
| 1896306 | 2024-02-25 00:00:00 | 79 | 803 | 2.117572 | 21.978279 | 5.479452 | 67.372354 | Manhattan | East Village | 72.851806 | 0 | 6 | True | 2 | 2024 |
EN / A detailed inspection of the highest trip counts confirms that extreme values are concentrated in central Manhattan zones and occur during peak demand hours, supporting their interpretation as genuine high-demand observations.
FR / L’analyse détaillée des valeurs les plus élevées de trajets montre qu’elles sont concentrées dans des zones centrales de Manhattan et surviennent lors de périodes de forte demande, confirmant leur caractère structurel.
2.3 Payment Percentage Consistency Check¶
df['pct_sum'] = df['pct_cash'] + df['pct_card']
df['pct_sum'].describe()
count 2.819948e+06 mean 8.178977e+01 std 3.035457e+01 min 0.000000e+00 25% 8.000000e+01 50% 9.628099e+01 75% 1.000000e+02 max 1.000000e+02 Name: pct_sum, dtype: float64
plt.figure(figsize=(8,4))
sns.histplot(df['pct_sum'], bins=50, color='gold')
plt.title("Distribution of pct_cash + pct_card")
plt.show()
EN / The sum of pct_cash and pct_card is equal to 100% for the majority of observations, indicating consistent payment share calculations.
Lower values are expected, as other payment types are not included in the analysis.
Observations with a zero sum correspond to zone-hour combinations where all trips were paid using alternative payment methods.
FR / La somme de pct_cash et pct_card est égale à 100 % pour la majorité des observations, ce qui confirme la cohérence du calcul des parts de paiement.
Les valeurs inférieures à 100 % sont attendues, d’autres modes de paiement n’étant pas inclus dans l’analyse.
Les observations à somme nulle correspondent à des combinaisons zone–heure où les trajets ont été réglés exclusivement via des modes de paiement alternatifs.
df['pct_sum'].between(0, 100).all()
True
All payment shares fall within valid bounds [0, 100].
2.4 Duplicate Records Verification¶
df.duplicated(subset=['hour_ts', 'pu_location_id']).sum()
0
EN / No duplicate observations were found at the hour–zone level.
FR / Aucun doublon n’a été détecté au niveau heure–zone.
2.5 Chronological Sorting¶
df = df.sort_values(['hour_ts', 'pu_location_id']).reset_index(drop=True)
df.head()
| hour_ts | pu_location_id | trips | avg_trip_distance | avg_total_amount | pct_cash | pct_card | borough | zone | pct_sum | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2022-01-01 | 4 | 11 | 3.200000 | 18.520909 | 54.545455 | 45.454545 | Manhattan | Alphabet City | 100.000000 |
| 1 | 2022-01-01 | 7 | 6 | 3.531667 | 15.353333 | 50.000000 | 16.666667 | Queens | Astoria | 66.666667 |
| 2 | 2022-01-01 | 10 | 1 | 7.910000 | 25.300000 | 0.000000 | 100.000000 | Queens | Baisley Park | 100.000000 |
| 3 | 2022-01-01 | 12 | 2 | 6.640000 | 29.025000 | 50.000000 | 50.000000 | Manhattan | Battery Park | 100.000000 |
| 4 | 2022-01-01 | 13 | 12 | 4.325000 | 22.370000 | 25.000000 | 66.666667 | Manhattan | Battery Park City | 91.666667 |
EN / The dataset was sorted chronologically by timestamp and pickup location identifier to ensure temporal consistency.
This ordering facilitates time-based analysis, feature engineering, and prevents information leakage in downstream modeling steps.
-FR / Le dataset a été trié chronologiquement par horodatage et identifiant de zone de prise en charge afin de garantir la cohérence temporelle.
Ce tri facilite les analyses temporelles, la création de variables temporelles et permet d’éviter toute fuite d’information lors des étapes de modélisation ultérieures.
2.6 Temporal Feature Preparation¶
df['hour'] = df['hour_ts'].dt.hour
df['weekday'] = df['hour_ts'].dt.weekday # 0 = Monday
df['is_weekend'] = df['weekday'].isin([5, 6])
df['month'] = df['hour_ts'].dt.month
df['year'] = df['hour_ts'].dt.year
df[['hour_ts', 'hour', 'weekday', 'is_weekend', 'month', 'year']].head()
| hour_ts | hour | weekday | is_weekend | month | year | |
|---|---|---|---|---|---|---|
| 0 | 2022-01-01 | 0 | 5 | True | 1 | 2022 |
| 1 | 2022-01-01 | 0 | 5 | True | 1 | 2022 |
| 2 | 2022-01-01 | 0 | 5 | True | 1 | 2022 |
| 3 | 2022-01-01 | 0 | 5 | True | 1 | 2022 |
| 4 | 2022-01-01 | 0 | 5 | True | 1 | 2022 |
EN / These variables enable the analysis of intraday, weekly, and seasonal demand patterns and support time-aware modeling.
FR / Ces variables permettent d’analyser les dynamiques intra-journalières, hebdomadaires et saisonnières de la demande et facilitent les modèles tenant compte du temps.
EN / The dataset has been thoroughly cleaned and validated. No major data quality issues were identified, and no corrective transformations were required at this stage.
FR / Le dataset a été nettoyé et validé de manière approfondie. Aucun problème majeur de qualité des données n’a été identifié et aucune transformation corrective n’a été nécessaire à ce stade.
Step 3 : Exploratory Data Analysis (EDA)
Objective :¶
Explore temporal and spatial patterns of taxi demand in order to better understand customer behavior and demand dynamics.
Objectif :¶
Explorer les dynamiques temporelles et spatiales de la demande de taxis afin de mieux comprendre les comportements clients et la structure de la demande.
3.1 Global Demand Distribution¶
Question¶
EN: How is taxi demand distributed overall?
FR: Comment se répartit globalement la demande de taxis ?
df['trips'].describe()
count 2.819948e+06 mean 4.224735e+01 std 7.519024e+01 min 1.000000e+00 25% 1.000000e+00 50% 5.000000e+00 75% 5.000000e+01 max 1.239000e+03 Name: trips, dtype: float64
plt.figure(figsize=(8,4))
sns.histplot(df['trips'], bins=50, color='gold')
plt.title("Distribution of Hourly Taxi Demand per Zone")
plt.xlabel("Number of trips")
plt.show()
EN / The distribution of hourly taxi trips per zone is highly right-skewed.
Most zone–hour observations correspond to low demand levels, while a small number of cases exhibit very high numbers of trips.
This indicates that taxi demand is unevenly distributed across zones and time periods.
FR / La distribution du nombre de trajets horaires par zone est fortement asymétrique à droite.
La majorité des observations zone–heure correspond à des niveaux de demande faibles, tandis qu’un nombre limité de cas présente des volumes de trajets très élevés.
Cela montre que la demande de taxis est répartie de manière inégale selon les zones et les périodes.
3.2 Hourly Demand Pattern¶
Question¶
EN: How does demand vary throughout the day?
FR: Comment la demande évolue-t-elle au cours de la journée ?
hourly_pattern = (
df.groupby('hour')['trips']
.mean()
.reset_index()
)
plt.figure(figsize=(10,5))
sns.lineplot(data=hourly_pattern, x='hour', y='trips', marker='o',color='gold')
plt.title("Average Taxi Demand by Hour of Day")
plt.xlabel("Hour of day")
plt.ylabel("Average number of trips")
plt.savefig("averageTaxiDemandByHour.png",dpi=300, bbox_inches="tight")
plt.show()
EN / Taxi demand follows a clear intraday pattern.
Demand is lowest during the early morning hours, increases steadily after 6 a.m., and peaks in the late afternoon and early evening.
After the peak, demand gradually declines toward nighttime.
This pattern highlights the importance of time-of-day effects in taxi demand.
FR / La demande de taxis suit un schéma intra-journalier bien marqué.
Elle est minimale en début de matinée, augmente progressivement à partir de 6 h, puis atteint un pic en fin d’après-midi et en début de soirée.
Après ce pic, la demande diminue progressivement au cours de la nuit.
Ce schéma met en évidence l’importance de l’heure de la journée dans la dynamique de la demande.
3.3 Weekday vs Weekend Demand¶
Question¶
EN: Is taxi demand different on weekends?
FR: La demande est-elle différente le week-end ?
week_pattern = (
df.groupby('is_weekend')['trips']
.mean()
.reset_index()
)
week_pattern['is_weekend'] = week_pattern['is_weekend'].map(
{False: 'Weekday', True: 'Weekend'}
)
sns.barplot(data=week_pattern, x='is_weekend', y='trips', color='gold')
plt.title("Average Taxi Demand: Weekday vs Weekend")
plt.ylabel("Average number of trips")
plt.show()
EN / Average taxi demand is slightly higher on weekdays than on weekends.
While demand remains relatively high during weekends, the difference suggests stronger and more consistent taxi usage during working days.
The difference represents approximately a 4–5% increase in average demand on weekdays.
FR / La demande moyenne de taxis est légèrement plus élevée en semaine que le week-end.
Bien que la demande reste soutenue durant le week-end, cet écart suggère une utilisation plus forte et plus régulière des taxis les jours ouvrés.
Cet écart représente environ une augmentation de 4–5 % de la demande moyenne en semaine.
3.4 Spatial Analysis – Demand by Borough¶
Question¶
EN: Which boroughs generate the highest taxi demand?
FR: Quels boroughs génèrent le plus de demande taxi ?
borough_demand = (
df.groupby('borough')['trips']
.mean()
.sort_values(ascending=False)
.reset_index()
)
plt.figure(figsize=(8,5))
sns.barplot(data=borough_demand, x='trips', y='borough', color='gold')
plt.title("Average Taxi Demand by Borough")
plt.xlabel("Average number of trips")
plt.show()
EN / Taxi demand varies strongly across boroughs.
Manhattan clearly generates the highest average number of trips, far exceeding other boroughs.
Queens follows at a significantly lower level, while Brooklyn, the Bronx, and Staten Island exhibit much lower average demand.
The EWR category represents airport-related trips outside New York City and shows lower average demand due to its specific and localized usage.
Categories labeled as “Unknown” or “N/A” correspond to records with missing or unmapped borough information and do not represent actual boroughs.
FR / La demande de taxis varie fortement selon les boroughs.
Manhattan génère de loin le plus grand nombre moyen de trajets, largement supérieur aux autres boroughs.
Queens arrive ensuite à un niveau nettement inférieur, tandis que Brooklyn, le Bronx et Staten Island présentent une demande moyenne beaucoup plus faible.
La catégorie EWR correspond aux trajets liés à l’aéroport de Newark, situé en dehors de New York City, et affiche une demande moyenne plus faible en raison de son usage spécifique et localisé.
Les catégories “Unknown” et “N/A” correspondent à des enregistrements dont l’information de borough est manquante ou non renseignée et ne représentent pas des boroughs réels.
3.5 Top Pickup Zones¶
Question¶
EN: Which pickup zones are the most active?
FR: Quelles zones de prise en charge sont les plus actives ?
top_zones = (
df.groupby('zone')['trips']
.mean()
.sort_values(ascending=False)
.head(15)
.reset_index()
)
plt.figure(figsize=(8,6))
sns.barplot(data=top_zones, x='trips', y='zone', color='gold')
plt.title("Top 15 Pickup Zones by Average Demand")
plt.xlabel("Average number of trips")
plt.show()
EN / Taxi demand is highly concentrated in a limited number of pickup zones.
The most active zones are primarily located in Manhattan, particularly in Midtown and Upper East Side areas, reflecting dense commercial and residential activity.
Major transportation hubs such as JFK Airport and LaGuardia Airport also appear among the top pickup zones, highlighting their role as key demand generators.
Airport zones exhibit high average demand due to concentrated passenger flows, whereas central urban zones reflect sustained and diversified taxi usage.
FR / La demande de taxis est fortement concentrée dans un nombre limité de zones de prise en charge.
Les zones les plus actives se situent majoritairement à Manhattan, notamment dans les secteurs de Midtown et de l’Upper East Side, ce qui reflète une forte densité d’activités commerciales et résidentielles.
Les grands pôles de transport tels que les aéroports JFK et LaGuardia figurent également parmi les zones les plus actives, soulignant leur rôle central dans la génération de la demande.
Les zones aéroportuaires présentent une forte demande moyenne en raison de flux de passagers concentrés, tandis que les zones centrales urbaines reflètent une utilisation plus continue et diversifiée des taxis.
3.6 Demand Evolution Over Time¶
Question¶
EN: How does taxi demand evolve over the 2022–2024 period?
FR: Comment la demande taxi évolue-t-elle entre 2022 et 2024 ?
daily_demand = (
df.groupby(df['hour_ts'].dt.date)['trips']
.sum()
.reset_index()
)
plt.figure(figsize=(12,5))
plt.plot(daily_demand['hour_ts'], daily_demand['trips'],color='gold')
plt.title("Daily Taxi Demand Over Time (2022–2024)")
plt.xlabel("Date")
plt.ylabel("Total number of trips")
plt.show()
EN / Taxi demand shows a clear upward trend over the 2022–2024 period.
Daily demand fluctuates strongly, displaying regular short-term variations and recurring peaks and troughs.
Overall, demand levels in 2024 appear higher than in 2022, suggesting a gradual recovery and growth over time.
FR / La demande de taxis présente une tendance globale à la hausse sur la période 2022–2024.
La demande quotidienne fluctue fortement, avec des variations régulières à court terme et des pics et creux récurrents.
Globalement, les niveaux de demande observés en 2024 sont plus élevés qu’en 2022, ce qui suggère une reprise et une croissance progressive au fil du temps.
3.7 Key EDA Takeaways - EN¶
Taxi demand is highly unevenly distributed, with most zone–hour combinations exhibiting low activity and a small number of cases showing very high demand.
Demand follows a strong intraday pattern, with low activity during early morning hours and peak demand occurring in the late afternoon and early evening.
Average taxi demand is slightly higher on weekdays than on weekends, indicating a small but consistent weekday effect.
Spatial analysis shows that Manhattan dominates taxi demand, far exceeding other boroughs, while Queens plays a secondary role.
Taxi activity is highly concentrated in specific pickup zones, particularly central Manhattan areas and major transportation hubs such as JFK and LaGuardia airports.
Over the 2022–2024 period, taxi demand exhibits strong short-term fluctuations and a clear upward long-term trend, suggesting a gradual recovery and growth in demand.
3.7 Key EDA Takeaways - FR¶
La demande de taxis est très inégalement répartie : la majorité des combinaisons zone–heure présentent une faible activité, tandis qu’un nombre limité de cas affiche des niveaux de demande très élevés.
La demande suit un schéma intra-journalier marqué, avec une activité faible en début de matinée et un pic en fin d’après-midi et en début de soirée.
La demande moyenne est légèrement plus élevée en semaine que le week-end, ce qui met en évidence un effet semaine modéré mais constant.
L’analyse spatiale montre que Manhattan domine largement la demande de taxis, devant les autres boroughs, tandis que Queens occupe une position secondaire.
L’activité taxi est fortement concentrée dans certaines zones de prise en charge, notamment les zones centrales de Manhattan et les grands pôles de transport comme les aéroports JFK et LaGuardia.
Sur la période 2022–2024, la demande de taxis présente de fortes fluctuations à court terme ainsi qu’une tendance globale à la hausse, suggérant une reprise et une croissance progressives.
Step 4 : Feature Engineering
Objective :¶
Create meaningful features from temporal and historical patterns to improve taxi demand prediction.
Objectif :¶
Créer des variables explicatives pertinentes à partir des dynamiques temporelles et historiques afin d’améliorer la prédiction de la demande de taxis.
4.1 Define Target and Base Features¶
EN / At this stage, we define the prediction target and identify the base features already available in the dataset.
The analysis is conducted at an hourly and pickup-zone level.
- Target variable: number of taxi trips per hour and per pickup zone
- Granularity: hour × pickup zone
FR / À cette étape, nous définissons la variable à prédire ainsi que les variables de base déjà disponibles dans le jeu de données.
L’analyse est réalisée à une granularité heure × zone de prise en charge.
- Variable cible : nombre de courses de taxi par heure et par zone
- Granularité : heure × zone
target = 'trips'
#base features : hour, weekday, is_weekend, month, year, borough, zone
4.2 Lag Features (historical demand)¶
EN
Taxi demand exhibits strong temporal autocorrelation: past demand levels are highly informative of future demand.
To capture this behavior, lagged features are created at different time horizons.
The following lag features are considered:
- 1-hour lag (short-term dependency)
- 24-hour lag (daily seasonality)
- 168-hour lag (weekly seasonality)
FR
La demande de taxis présente une forte autocorrélation temporelle : la demande passée est très informative de la demande future.
Afin de capturer ce comportement, des variables de retard (lags) sont créées à différents horizons temporels.
Les retards suivants sont considérés :
- 1 heure (dépendance court terme)
- 24 heures (saisonnalité journalière)
- 168 heures (saisonnalité hebdomadaire)
df = df.sort_values(["zone", "hour_ts"])
for lag in [1, 24, 168]:
df[f"trips_lag_{lag}h"] = (
df.groupby("zone")["trips"]
.shift(lag)
)
#check
df[
["zone", "hour_ts", "trips", "trips_lag_1h", "trips_lag_24h", "trips_lag_168h"]
]
#les NaN ne sont pas des erreurs , ça veut juste dire "pas assez d'historique" on les supprimera plus tard (4.4)
| zone | hour_ts | trips | trips_lag_1h | trips_lag_24h | trips_lag_168h | |
|---|---|---|---|---|---|---|
| 404 | Allerton/Pelham Gardens | 2022-01-01 04:00:00 | 1 | NaN | NaN | NaN |
| 1207 | Allerton/Pelham Gardens | 2022-01-01 13:00:00 | 1 | 1.0 | NaN | NaN |
| 9541 | Allerton/Pelham Gardens | 2022-01-05 12:00:00 | 1 | 1.0 | NaN | NaN |
| 9649 | Allerton/Pelham Gardens | 2022-01-05 13:00:00 | 1 | 1.0 | NaN | NaN |
| 10062 | Allerton/Pelham Gardens | 2022-01-05 17:00:00 | 1 | 1.0 | NaN | NaN |
| ... | ... | ... | ... | ... | ... | ... |
| 2819245 | Yorkville West | 2024-12-31 19:00:00 | 151 | 147.0 | 51.0 | 91.0 |
| 2819395 | Yorkville West | 2024-12-31 20:00:00 | 238 | 151.0 | 64.0 | 101.0 |
| 2819577 | Yorkville West | 2024-12-31 21:00:00 | 252 | 238.0 | 78.0 | 90.0 |
| 2819772 | Yorkville West | 2024-12-31 22:00:00 | 196 | 252.0 | 58.0 | 91.0 |
| 2819945 | Yorkville West | 2024-12-31 23:00:00 | 97 | 196.0 | 37.0 | 53.0 |
2819948 rows × 6 columns
EN / Lag features were created to capture short-term, daily, and weekly demand dependencies.
FR / Des variables de retard ont été créées afin de capturer les dépendances de la demande à court terme, journalières et hebdomadaires.
4.3 Rolling Statistics (Moving Averages)¶
EN
In addition to lag features, rolling statistics are used to smooth short-term fluctuations
and capture local demand trends.
Moving averages help reduce noise and improve model stability.
Two rolling windows are considered:
- 24 hours (daily trend)
- 168 hours (weekly trend)
FR
En complément des variables de retard, des statistiques glissantes sont utilisées afin de lisser
les fluctuations à court terme et de capturer les tendances locales de la demande.
Les moyennes mobiles permettent de réduire le bruit et d’améliorer la stabilité du modèle.
Deux fenêtres temporelles sont considérées :
- 24 heures (tendance journalière)
- 168 heures (tendance hebdomadaire)
df["trips_roll_mean_24h"] = (
df.groupby("zone")["trips"]
.rolling(window=24)
.mean()
.reset_index(level=0, drop=True)
)
df["trips_roll_mean_168h"] = (
df.groupby("zone")["trips"]
.rolling(window=168)
.mean()
.reset_index(level=0, drop=True)
)
#check
df[
[
"zone",
"hour_ts",
"trips",
"trips_roll_mean_24h",
"trips_roll_mean_168h"
]
]
#lag = mémoire ponctuelle
#rolling mean = tendance
# les 2 sont complémentaires , jamais redondants
# résultat parfaitement cohérent métier :
#moyenne 24h > moyenne 168h → activité récente plus forte
#rolling mean < valeur instantanée → pic horaire
| zone | hour_ts | trips | trips_roll_mean_24h | trips_roll_mean_168h | |
|---|---|---|---|---|---|
| 404 | Allerton/Pelham Gardens | 2022-01-01 04:00:00 | 1 | NaN | NaN |
| 1207 | Allerton/Pelham Gardens | 2022-01-01 13:00:00 | 1 | NaN | NaN |
| 9541 | Allerton/Pelham Gardens | 2022-01-05 12:00:00 | 1 | NaN | NaN |
| 9649 | Allerton/Pelham Gardens | 2022-01-05 13:00:00 | 1 | NaN | NaN |
| 10062 | Allerton/Pelham Gardens | 2022-01-05 17:00:00 | 1 | NaN | NaN |
| ... | ... | ... | ... | ... | ... |
| 2819245 | Yorkville West | 2024-12-31 19:00:00 | 151 | 66.833333 | 56.773810 |
| 2819395 | Yorkville West | 2024-12-31 20:00:00 | 238 | 74.083333 | 57.589286 |
| 2819577 | Yorkville West | 2024-12-31 21:00:00 | 252 | 81.333333 | 58.553571 |
| 2819772 | Yorkville West | 2024-12-31 22:00:00 | 196 | 87.083333 | 59.178571 |
| 2819945 | Yorkville West | 2024-12-31 23:00:00 | 97 | 89.583333 | 59.440476 |
2819948 rows × 5 columns
EN / Rolling mean features were created to capture daily and weekly demand trends while smoothing short-term noise.
FR / Des moyennes mobiles ont été créées afin de capturer les tendances journalières et hebdomadaires de la demande tout en lissant le bruit à court terme.
4.4 Handle NaN Values¶
EN / Missing values observed at this stage are a direct consequence of lag and rolling feature creation.
They correspond to observations with insufficient historical context and are therefore removed
to ensure model consistency.
FR / Les valeurs manquantes observées à ce stade sont une conséquence directe de la création des variables
de retard et des moyennes mobiles.
Elles correspondent à des observations ne disposant pas d’un historique suffisant et sont donc supprimées
afin de garantir la cohérence du modèle.
feature_cols = [
"hour",
"weekday",
"is_weekend",
"month",
"trips_lag_1h",
"trips_lag_24h",
"trips_lag_168h",
"trips_roll_mean_24h",
"trips_roll_mean_168h"
]
df_model = df.dropna(subset=feature_cols + ["trips"])
#check
df_model.isna().sum()
hour_ts 0 pu_location_id 0 trips 0 avg_trip_distance 0 avg_total_amount 213 pct_cash 0 pct_card 0 borough 0 zone 0 pct_sum 0 hour 0 weekday 0 is_weekend 0 month 0 year 0 trips_lag_1h 0 trips_lag_24h 0 trips_lag_168h 0 trips_roll_mean_24h 0 trips_roll_mean_168h 0 dtype: int64
#check
df_model.shape
(2777118, 20)
EN / Observations without sufficient historical context were removed, resulting in a clean and model-ready dataset
Monetary average features are preserved in the dataset for business analysis and dashboarding purposes. However, they are excluded from the demand prediction model, as the modeling objective focuses solely on forecasting taxi demand rather than revenue.
FR / Les observations ne disposant pas d’un historique suffisant ont été supprimées, aboutissant à un jeu de données propre et prêt pour la modélisation.
Les variables monétaires moyennes sont conservées dans le jeu de données à des fins d’analyse métier et de visualisation. En revanche, elles sont exclues du modèle de prédiction, l’objectif étant exclusivement la prévision de la demande et non du chiffre d’affaires.
4.5 Encoding Categorical Variables¶
EN / Machine learning models require numerical inputs.
Categorical variables must therefore be encoded before model training.
In this project, only the borough variable is encoded, as it contains a limited number of categories
and provides meaningful spatial information.
The zone variable is intentionally excluded from encoding to avoid high-dimensionality and overfitting.
FR / Les modèles de machine learning nécessitent des entrées numériques.
Les variables catégorielles doivent donc être encodées avant l’entraînement du modèle.
Dans ce projet, seule la variable borough est encodée, car elle contient un nombre limité de catégories
et apporte une information spatiale pertinente.
La variable zone est volontairement exclue de l’encodage afin d’éviter une forte dimensionnalité et le surapprentissage.
df_model = pd.get_dummies(
df_model,
columns=["borough"],
drop_first=False # je pourrai mettre en True pour retirer la colonne de reference car le model n'en a pas besoin en vrai
)
#check
df_model.filter(like="borough_")
| borough_Bronx | borough_Brooklyn | borough_EWR | borough_Manhattan | borough_N/A | borough_Queens | borough_Staten Island | borough_Unknown | |
|---|---|---|---|---|---|---|---|---|
| 293621 | True | False | False | False | False | False | False | False |
| 301601 | True | False | False | False | False | False | False | False |
| 301836 | True | False | False | False | False | False | False | False |
| 304465 | True | False | False | False | False | False | False | False |
| 306822 | True | False | False | False | False | False | False | False |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2819245 | False | False | False | True | False | False | False | False |
| 2819395 | False | False | False | True | False | False | False | False |
| 2819577 | False | False | False | True | False | False | False | False |
| 2819772 | False | False | False | True | False | False | False | False |
| 2819945 | False | False | False | True | False | False | False | False |
2777118 rows × 8 columns
4.6 Final Dataset for Modeling¶
EN / At this stage, the final dataset for machine learning is defined.
Only features relevant to taxi demand prediction and available at prediction time are included.
Monetary variables are intentionally excluded from the model, as the objective focuses solely on forecasting demand.
FR / À cette étape, le jeu de données final destiné à la modélisation est défini.
Seules les variables pertinentes pour la prédiction de la demande taxi et disponibles au moment de la prédiction sont conservées.
Les variables monétaires sont volontairement exclues du modèle, l’objectif étant exclusivement la prévision de la demande.
#target
y = df_model["trips"]
#features
feature_cols_final = [
# temporal features
"hour",
"weekday",
"is_weekend",
"month",
# lag features
"trips_lag_1h",
"trips_lag_24h",
"trips_lag_168h",
# rolling statistics
"trips_roll_mean_24h",
"trips_roll_mean_168h",
]
# ajouter les colonnes borough encodées
feature_cols_final += [
col for col in df_model.columns if col.startswith("borough_")
]
#Dataset final ML
X = df_model[feature_cols_final]
#check
X.shape, y.shape
((2777118, 17), (2777118,))
#check
X.isna().sum().sum()
0
#check
X.head()
| hour | weekday | is_weekend | month | trips_lag_1h | trips_lag_24h | trips_lag_168h | trips_roll_mean_24h | trips_roll_mean_168h | borough_Bronx | borough_Brooklyn | borough_EWR | borough_Manhattan | borough_N/A | borough_Queens | borough_Staten Island | borough_Unknown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 293621 | 23 | 5 | True | 5 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.071429 | True | False | False | False | False | False | False | False |
| 301601 | 9 | 2 | False | 5 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.071429 | True | False | False | False | False | False | False | False |
| 301836 | 11 | 2 | False | 5 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.071429 | True | False | False | False | False | False | False | False |
| 304465 | 13 | 3 | False | 5 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.071429 | True | False | False | False | False | False | False | False |
| 306822 | 12 | 4 | False | 5 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.071429 | True | False | False | False | False | False | False | False |
EN / A clean and well-defined modeling dataset was constructed, incorporating temporal, historical, and spatial features suitable for taxi demand forecasting.
FR / Un jeu de données de modélisation propre et clairement défini a été construit, intégrant des variables temporelles, historiques et spatiales adaptées à la prévision de la demande de taxis.
Step 5 : Modeling (ML)
Objective:¶
Predict the number of taxi trips trips for a given hour × zone using historical demand and temporal patterns.
Objectif:¶
Prédire le nombre de trajets taxi trips pour une zone donnée à une heure donnée, à partir de l’historique et des variables temporelles.
EN/ We split the dataset temporally to simulate a real-world forecasting scenario, ensuring that the model is trained only on past data and evaluated on future observations.
FR/ Nous effectuons une séparation temporelle des données afin de reproduire un scénario réel de prédiction, où le modèle apprend uniquement sur le passé et est évalué sur le futur.
5.1 Train/Test Split (Time-aware)¶
EN / To evaluate the model in a realistic forecasting setting, we split the data chronologically.
The model is trained on past observations (2022–2023) and evaluated on future observations (2024).
This prevents data leakage and better reflects real-world performance.
FR / Pour évaluer le modèle dans un contexte réaliste de prévision, nous séparons les données chronologiquement.
Le modèle est entraîné sur le passé (2022–2023) et évalué sur le futur (2024).
Cela évite les fuites de données et reflète mieux la performance en conditions réelles.
# Define split date (start of test period)
split_date = pd.Timestamp("2024-01-01")
# Boolean masks
train_mask = df_model["hour_ts"] < split_date
test_mask = df_model["hour_ts"] >= split_date
# Split
X_train, X_test = X[train_mask], X[test_mask]
y_train, y_test = y[train_mask], y[test_mask]
#check
X_train.shape, X_test.shape, y_train.shape, y_test.shape
((1716184, 17), (1060934, 17), (1716184,), (1060934,))
#sanity check
df_model.loc[train_mask, "hour_ts"].min(), df_model.loc[train_mask, "hour_ts"].max()
(Timestamp('2022-01-08 00:00:00'), Timestamp('2023-12-31 23:00:00'))
#sanity check
df_model.loc[test_mask, "hour_ts"].min(), df_model.loc[test_mask, "hour_ts"].max()
(Timestamp('2024-01-01 00:00:00'), Timestamp('2024-12-31 23:00:00'))
5.2 Baseline Model (Naive Forecast)¶
# Baseline prediction: persistence model
y_pred_baseline = X_test["trips_lag_1h"]
#The baseline model is evaluated on the test set only, as performance must be assessed on unseen future data to reflect real-world forecasting conditions.
#Le modèle de référence est évalué uniquement sur le jeu de test, car la performance doit être mesurée sur des données futures non vues afin de refléter un scénario réel de prévision.
EN / A naive baseline model was implemented using a persistence approach, where the predicted demand equals the observed demand one hour earlier. This baseline provides a lower bound for model performance.
FR / Un modèle de référence naïf a été implémenté selon une approche de persistance, où la demande prédite correspond à la demande observée une heure auparavant. Ce modèle sert de borne inférieure de performance.
from sklearn.metrics import mean_absolute_error, mean_squared_error
import numpy as np
mae_baseline = mean_absolute_error(y_test, y_pred_baseline)
rmse_baseline = np.sqrt(mean_squared_error(y_test, y_pred_baseline))
mae_baseline, rmse_baseline
(9.894434526558674, 23.051931697546312)
y_pred_baseline.isna().sum() # si 0 = pipeline propre
0
EN / The Mean Absolute Error (MAE) of approximately 10 trips indicates that, on average, predictions deviate from actual demand by about ten trips per hour and per zone.
The higher RMSE value highlights the presence of demand spikes, which are not well captured by a simple lag-based approach.
These results establish a lower bound for model performance and motivate the use of more advanced machine learning models.
FR / Une erreur absolue moyenne (MAE) d’environ 10 courses indique qu’en moyenne, les prédictions s’écartent de la demande réelle d’une dizaine de courses par heure et par zone.
La valeur plus élevée du RMSE met en évidence la présence de pics de demande que ce modèle simple ne parvient pas à capturer.
Ces résultats constituent une borne inférieure de performance et justifient l’utilisation de modèles de machine learning plus avancés.
5.3 Random Forest Regressor¶
EN / Random Forest is an ensemble learning method that combines multiple decision trees trained on random subsets of data and features. By averaging their predictions, it reduces variance and improves generalization, making it well suited for non-linear regression problems with complex feature interactions.
It serves as a strong and interpretable benchmark for demand forecasting.
FR / Le Random Forest est une méthode d’apprentissage ensembliste qui combine plusieurs arbres de décision entraînés sur des sous-ensembles aléatoires de données et de variables. En moyennant leurs prédictions, il réduit la variance et améliore la généralisation, ce qui le rend particulièrement adapté aux problèmes de régression non linéaires avec interactions complexes.
Il constitue un excellent modèle de référence pour la prévision de la demande.
from sklearn.ensemble import RandomForestRegressor
rf = RandomForestRegressor(
n_estimators=100,
max_depth=20,
random_state=42,
n_jobs=-1
)
rf.fit(X_train, y_train)
RandomForestRegressor(max_depth=20, n_jobs=-1, random_state=42)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
RandomForestRegressor(max_depth=20, n_jobs=-1, random_state=42)
y_pred_rf = rf.predict(X_test)
mae_rf = mean_absolute_error(y_test, y_pred_rf)
rmse_rf = np.sqrt(mean_squared_error(y_test, y_pred_rf))
mae_rf, rmse_rf
(6.356378538826315, 14.664632361972222)
comparison_df = pd.DataFrame({
"Model": ["Baseline (Lag-1)", "Random Forest"],
"MAE": [mae_baseline, mae_rf],
"RMSE": [rmse_baseline, rmse_rf]
})
comparison_df.round(2)
| Model | MAE | RMSE | |
|---|---|---|---|
| 0 | Baseline (Lag-1) | 9.89 | 23.05 |
| 1 | Random Forest | 6.36 | 14.66 |
EN / The Random Forest model significantly outperformed the naive persistence baseline. MAE decreased from ~9.9 to ~6.4 and RMSE from ~23.1 to ~14.7, showing improved accuracy both on average and during demand peaks.
This confirms that temporal and historical features capture meaningful predictive patterns beyond simple persistence.
FR / Le modèle Random Forest surpasse nettement le modèle naïf de persistance. La MAE passe d’environ 9,9 à 6,4 et le RMSE de 23,1 à 14,7, indiquant une amélioration à la fois sur l’erreur moyenne et sur les pics de demande.
Cela confirme que les variables temporelles et historiques capturent des dynamiques prédictives pertinentes au-delà d’une simple persistance.
5.4 Feature Importance¶
feature_importance = pd.DataFrame({
"feature": X_train.columns,
"importance": rf.feature_importances_
}).sort_values(by="importance", ascending=False)
feature_importance.head(17)
| feature | importance | |
|---|---|---|
| 4 | trips_lag_1h | 9.126270e-01 |
| 5 | trips_lag_24h | 3.356686e-02 |
| 0 | hour | 1.605595e-02 |
| 6 | trips_lag_168h | 1.454877e-02 |
| 7 | trips_roll_mean_24h | 9.257865e-03 |
| 8 | trips_roll_mean_168h | 6.347834e-03 |
| 1 | weekday | 3.522569e-03 |
| 3 | month | 2.159529e-03 |
| 14 | borough_Queens | 7.753297e-04 |
| 12 | borough_Manhattan | 6.087721e-04 |
| 2 | is_weekend | 5.031331e-04 |
| 16 | borough_Unknown | 1.863250e-05 |
| 13 | borough_N/A | 4.122380e-06 |
| 10 | borough_Brooklyn | 3.400801e-06 |
| 11 | borough_EWR | 1.267095e-07 |
| 9 | borough_Bronx | 9.415391e-08 |
| 15 | borough_Staten Island | 2.992754e-09 |
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 6))
plt.barh(
feature_importance["feature"].head(17)[::-1],
feature_importance["importance"].head(17)[::-1],
color='gold'
)
for i, v in enumerate(feature_importance["importance"].head(17)[::-1]):
plt.text(v, i, f"{v:.3f}", va="center")
plt.title("Top 15 Feature Importances (Random Forest)")
plt.xlabel("Importance")
plt.ylabel("Feature")
plt.tight_layout()
plt.show()
EN / The feature importance analysis reveals that short-term temporal dependencies dominate taxi demand prediction.
The most influential feature by far is trips_lag_1h, accounting for more than 90% of the total importance, indicating that the demand observed one hour earlier is the strongest predictor of current demand.
Additional lag-based features (trips_lag_24h, trips_lag_168h) and rolling means contribute marginally, capturing daily and weekly seasonal patterns.
Calendar-related features such as hour, weekday, and month have a smaller but non-negligible impact, reflecting regular temporal structures in taxi usage.
Spatial features (borough indicators) have relatively low importance, suggesting that temporal dynamics are more informative than spatial location once historical demand is known.
FR / L’analyse des importances des variables montre que la demande passée explique largement la demande future.
La variable la plus influente est trips_lag_1h, qui représente plus de 90 % de l’importance totale, ce qui indique que la demande observée une heure auparavant est le meilleur prédicteur de la demande actuelle.
Les autres variables de retard (trips_lag_24h, trips_lag_168h) ainsi que les moyennes mobiles capturent des effets saisonniers journaliers et hebdomadaires, mais avec un impact plus limité.
Les variables temporelles (hour, weekday, month) jouent un rôle secondaire mais cohérent avec les cycles réguliers de la demande.
Les variables spatiales (boroughs) ont une importance relativement faible, suggérant que la dynamique temporelle prime sur la localisation une fois l’historique de demande pris en compte.
5.5 Error analysis (où le modèle se trompe)¶
#création d'une table d'analyse des erreurs
#un résidu = réalité - prédiction
# Residuals
df_errors = X_test.copy()
df_errors["y_true"] = y_test.values
df_errors["y_pred"] = y_pred_rf
df_errors["error"] = df_errors["y_true"] - df_errors["y_pred"]
df_errors["abs_error"] = df_errors["error"].abs()
df_errors.head()
| hour | weekday | is_weekend | month | trips_lag_1h | trips_lag_24h | trips_lag_168h | trips_roll_mean_24h | trips_roll_mean_168h | borough_Bronx | ... | borough_EWR | borough_Manhattan | borough_N/A | borough_Queens | borough_Staten Island | borough_Unknown | y_true | y_pred | error | abs_error | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1758081 | 1 | 0 | False | 1 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.095238 | True | ... | False | False | False | False | False | False | 1 | 1.013093 | -0.013093 | 0.013093 |
| 1761180 | 6 | 1 | False | 1 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.095238 | True | ... | False | False | False | False | False | False | 1 | 1.032762 | -0.032762 | 0.032762 |
| 1761310 | 7 | 1 | False | 1 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.095238 | True | ... | False | False | False | False | False | False | 1 | 1.053056 | -0.053056 | 0.053056 |
| 1762133 | 13 | 1 | False | 1 | 1.0 | 1.0 | 1.0 | 1.041667 | 1.095238 | True | ... | False | False | False | False | False | False | 1 | 1.032524 | -0.032524 | 0.032524 |
| 1762751 | 18 | 1 | False | 1 | 1.0 | 1.0 | 2.0 | 1.041667 | 1.089286 | True | ... | False | False | False | False | False | False | 1 | 1.057114 | -0.057114 | 0.057114 |
5 rows × 21 columns
plt.figure(figsize=(10, 5))
plt.hist(df_errors["abs_error"], bins=100, color='gold')
plt.title("Distribution of Absolute Prediction Errors")
plt.xlabel("Absolute Error (|y_true - y_pred|)")
plt.ylabel("Frequency")
plt.tight_layout()
plt.show()
EN/ The distribution of absolute prediction errors is highly right-skewed. Most observations exhibit very small errors, indicating that the model performs well for typical demand levels. However, a long tail of larger errors suggests that the model struggles during rare high-demand situations, such as peak hours or unusual events.
FR/ La distribution des erreurs absolues est fortement asymétrique à droite. La majorité des observations présentent des erreurs très faibles, ce qui indique de bonnes performances du modèle pour les niveaux de demande courants. En revanche, une longue traîne d’erreurs plus élevées montre que le modèle rencontre davantage de difficultés lors de situations de forte demande ou d’événements atypiques.
plt.figure(figsize=(10, 5))
plt.scatter(
df_errors["y_true"],
df_errors["abs_error"],
alpha=0.05,
color='black'
)
plt.xlabel("Observed trips")
plt.ylabel("Absolute error")
plt.title("Error vs Observed Demand")
plt.tight_layout()
plt.axhline(df_errors["abs_error"].median(), linestyle="--", color="gold")
plt.show()
EN/ The model shows strong predictive performance for the majority of observations, particularly when demand ranges from 0 to approximately 500 trips per hour. In this range, prediction errors remain compact and well-controlled. Larger and more dispersed errors mainly occur during rare high-demand situations, indicating that extreme demand spikes are more challenging to predict.
FR/ Le modèle présente de bonnes performances pour la grande majorité des observations, notamment lorsque la demande se situe entre 0 et environ 500 courses par heure. Dans cette plage, les erreurs de prédiction restent contenues et bien concentrées. Les erreurs plus importantes apparaissent principalement lors de situations de demande exceptionnellement élevée, qui sont rares mais plus difficiles à anticiper.
error_by_hour = df_errors.groupby("hour")["abs_error"].mean()
plt.figure(figsize=(10, 5))
error_by_hour.plot(color='gold')
plt.title("Mean Absolute Error by Hour of Day")
plt.xlabel("Hour of day")
plt.ylabel("Mean Absolute Error")
plt.grid(True)
plt.tight_layout()
plt.axvspan(7, 9, alpha=0.15, label="Morning rush",color='pink')
plt.axvspan(16, 19, alpha=0.15, label="Evening rush")
plt.axvspan(20, 23, alpha=0.15, label="Evening social hours",color='blue')
plt.legend()
peak_hour = error_by_hour.idxmax()
plt.annotate(
"Peak error (high volatility)",
xy=(peak_hour, error_by_hour[peak_hour]),
xytext=(peak_hour - 6, error_by_hour.max() - 1),
arrowprops=dict(arrowstyle="->")
)
plt.show()
EN / Prediction errors are lowest during early morning hours (4–6 AM), when taxi demand is low and stable. Errors increase during morning and evening transition periods, with the highest values observed in the late evening. This suggests that demand volatility during peak social activity hours is more difficult to capture using historical patterns alone.
FR / Les erreurs de prédiction sont les plus faibles durant les heures creuses du matin (4–6h), lorsque la demande est faible et stable. Les erreurs augmentent lors des périodes de transition (heures de pointe) et atteignent un maximum en soirée, indiquant une demande plus volatile liée aux activités sociales, plus difficile à prédire à partir des données historiques.
df_errors.sort_values("abs_error", ascending=False).head(10)
| hour | weekday | is_weekend | month | trips_lag_1h | trips_lag_24h | trips_lag_168h | trips_roll_mean_24h | trips_roll_mean_168h | borough_Bronx | ... | borough_EWR | borough_Manhattan | borough_N/A | borough_Queens | borough_Staten Island | borough_Unknown | y_true | y_pred | error | abs_error | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2639147 | 1 | 6 | True | 11 | 673.0 | 792.0 | 794.0 | 255.625000 | 134.482143 | False | ... | False | True | False | False | False | False | 1239 | 714.380000 | 524.620000 | 524.620000 |
| 1768137 | 21 | 3 | False | 1 | 308.0 | 154.0 | 150.0 | 186.333333 | 122.726190 | False | ... | False | True | False | False | False | False | 668 | 230.862550 | 437.137450 | 437.137450 |
| 2639183 | 1 | 6 | True | 11 | 397.0 | 456.0 | 350.0 | 138.291667 | 66.898810 | False | ... | False | True | False | False | False | False | 879 | 444.199279 | 434.800721 | 434.800721 |
| 2761827 | 21 | 3 | False | 12 | 381.0 | 697.0 | 318.0 | 219.083333 | 204.571429 | False | ... | False | True | False | False | False | False | 771 | 342.438322 | 428.561678 | 428.561678 |
| 2134968 | 22 | 4 | False | 5 | 259.0 | 285.0 | 369.0 | 179.916667 | 182.202381 | False | ... | False | True | False | False | False | False | 651 | 262.688156 | 388.311844 | 388.311844 |
| 2150218 | 23 | 2 | False | 5 | 648.0 | 146.0 | 588.0 | 192.250000 | 174.327381 | False | ... | False | True | False | False | False | False | 168 | 542.303333 | -374.303333 | 374.303333 |
| 1931999 | 22 | 4 | False | 3 | 269.0 | 424.0 | 180.0 | 162.875000 | 169.613095 | False | ... | False | True | False | False | False | False | 661 | 290.020369 | 370.979631 | 370.979631 |
| 2758773 | 21 | 2 | False | 12 | 389.0 | 635.0 | 310.0 | 223.291667 | 201.982143 | False | ... | False | True | False | False | False | False | 697 | 344.958818 | 352.041182 | 352.041182 |
| 1823095 | 22 | 4 | False | 1 | 206.0 | 324.0 | 156.0 | 144.500000 | 146.404762 | False | ... | False | True | False | False | False | False | 547 | 198.138736 | 348.861264 | 348.861264 |
| 2735441 | 0 | 2 | False | 12 | 381.0 | 514.0 | 244.0 | 246.625000 | 185.690476 | False | ... | False | False | False | True | False | False | 71 | 411.842734 | -340.842734 | 340.842734 |
10 rows × 21 columns
5.6 Conclusion & limits¶
Modeling Conclusion
EN / A Random Forest regression model was developed to predict hourly taxi demand by zone. Compared to a naive persistence baseline, the model significantly improved predictive performance, reducing both average errors (MAE) and large deviations (RMSE).
Feature importance analysis revealed that recent demand history (especially the previous hour) is the dominant driver of predictions, followed by daily and weekly temporal patterns.
Error analysis showed that the model performs well during stable demand periods, but struggles to anticipate rare and extreme demand spikes occurring mainly during late evening hours. Overall, the model provides reliable short-term demand forecasts under normal operating conditions.
FR / Un modèle de régression Random Forest a été développé afin de prédire la demande horaire de taxis par zone. Comparé à un modèle naïf de persistance, le modèle améliore significativement les performances de prédiction, avec une réduction notable de l’erreur moyenne (MAE) et des erreurs importantes (RMSE).
L’analyse de l’importance des variables montre que la demande récente — en particulier celle de l’heure précédente — constitue le principal facteur explicatif, complétée par des effets temporels journaliers et hebdomadaires.
L’analyse des erreurs indique que le modèle est performant dans des contextes de demande stable, mais présente des limites lors de pics exceptionnels survenant principalement en soirée. Dans l’ensemble, le modèle fournit des prévisions fiables de la demande à court terme dans des conditions normales.
Limitations
EN / Despite its strong performance, the model presents several limitations. First, it relies exclusively on historical demand and temporal features, and therefore cannot anticipate demand spikes driven by external factors such as weather conditions, public events, holidays, or disruptions.
Additionally, the model is optimized for short-term forecasting and may not generalize well to long-term demand shifts or structural changes in urban mobility patterns. Finally, prediction errors increase with demand magnitude, meaning that extreme high-demand hours are inherently more difficult to forecast accurately.
FR/ Malgré de bonnes performances globales, le modèle présente plusieurs limites. Il repose exclusivement sur l’historique de la demande et des variables temporelles, ce qui l’empêche d’anticiper des pics de demande liés à des facteurs externes tels que la météo, les événements publics, les jours fériés ou des perturbations exceptionnelles.
De plus, le modèle est principalement adapté à des prévisions de court terme et pourrait moins bien capturer des changements structurels de la mobilité urbaine sur le long terme. Enfin, les erreurs de prédiction augmentent avec l’intensité de la demande, rendant les heures de très forte affluence plus difficiles à prédire avec précision.
Business value EN/ This model can support operational decision-making by providing short-term demand forecasts at a fine spatial and temporal granularity.
Potential use cases include:
- Dynamic fleet allocation by zone and hour
- Anticipation of peak demand periods
- Improved staffing and dispatch planning
While the model should not be used as a sole decision-making tool during extreme events, it provides strong guidance under normal operating conditions.
FR/ Ce modèle peut soutenir la prise de décision opérationnelle en fournissant des prévisions de demande à court terme avec une granularité spatiale et temporelle fine.
Les cas d’usage potentiels incluent :
- L’allocation dynamique de la flotte par zone et par heure
- L’anticipation des périodes de forte affluence
- L’optimisation de la planification et du dispatch
Bien que le modèle ne doive pas être utilisé seul lors d’événements exceptionnels, il constitue un outil fiable dans des conditions d’exploitation normales.
Step 6 : Mini simulation de prédiction
EN/ Once trained, the model can be used to predict taxi demand for a given hour and zone by providing the same feature set used during training, including recent historical demand and temporal context.
FR/ Une fois entraîné, le modèle peut être utilisé pour prédire la demande de taxis pour une heure et une zone données, à condition de fournir les mêmes variables explicatives que lors de l’entraînement, notamment l’historique récent de la demande et le contexte temporel.
Démonstration :¶
def simulate_zone_forecast_walkforward(
df_model: pd.DataFrame,
model,
X_columns,
pu_location_id: int,
start_ts: str,
horizon: int ,
):
start_ts = pd.Timestamp(start_ts)
# --- 1) récupérer la série historique de cette zone (jusqu'à start_ts inclus)
zone_hist = df_model[df_model["pu_location_id"] == pu_location_id].copy()
zone_hist = zone_hist.sort_values("hour_ts")
if zone_hist.empty:
raise ValueError("Zone inconnue dans df_model.")
# On doit avoir le point start_ts
if start_ts not in set(zone_hist["hour_ts"]):
raise ValueError("start_ts n'existe pas pour cette zone. Choisis un timestamp existant.")
# On prend l'index du start
zone_hist = zone_hist.set_index("hour_ts")
t0 = start_ts
# Valeur vraie à t0 (utile pour démo)
y_true_t0 = float(zone_hist.loc[t0, "trips"])
# --- 2) buffer d'historique : on prend les 168 dernières heures (si dispo)
hist_series = zone_hist.loc[:t0, "trips"].copy()
hist_series = hist_series.tail(168) # 168h = 1 semaine
# helper: construire une ligne X pour un timestamp donné
def build_features(ts, hist):
hour = ts.hour
weekday = ts.weekday() # 0..6
is_weekend = weekday >= 5
month = ts.month
# lags (si pas assez d'historique, NaN)
lag_1h = hist.iloc[-1] if len(hist) >= 1 else np.nan
lag_24h = hist.iloc[-24] if len(hist) >= 24 else np.nan
lag_168h = hist.iloc[-168] if len(hist) >= 168 else np.nan
roll_24h = hist.tail(24).mean() if len(hist) >= 24 else np.nan
roll_168h = hist.tail(168).mean() if len(hist) >= 168 else np.nan
X = {
"hour": hour,
"weekday": weekday,
"is_weekend": is_weekend,
"month": month,
"trips_lag_1h": lag_1h,
"trips_lag_24h": lag_24h,
"trips_lag_168h": lag_168h,
"trips_roll_mean_24h": roll_24h,
"trips_roll_mean_168h": roll_168h,
}
# borough dummies: on reprend depuis df_model à t0 (zone fixe)
# on copie toutes les colonnes borough_* depuis une ligne existante (celle de t0)
row_t0 = zone_hist.loc[t0]
for c in X_columns:
if c.startswith("borough_"):
X[c] = bool(row_t0.get(c, False))
X_df = pd.DataFrame([X])
# compléter colonnes manquantes
for c in X_columns:
if c not in X_df.columns:
X_df[c] = 0
return X_df[X_columns]
preds = []
hist = hist_series.copy()
# --- 3) walk-forward prediction
for step in range(1, horizon + 1):
ts_next = t0 + pd.Timedelta(hours=step)
X_next = build_features(ts_next, hist)
y_hat = float(model.predict(X_next)[0])
# (option) éviter négatifs
y_hat = max(0.0, y_hat)
preds.append({
"forecast_for": ts_next,
"pu_location_id": pu_location_id,
"pred_trips": y_hat,
"used_lag_1h": float(X_next["trips_lag_1h"].iloc[0]),
"used_lag_24h": float(X_next["trips_lag_24h"].iloc[0]) if not pd.isna(X_next["trips_lag_24h"].iloc[0]) else None,
"used_roll_24h": float(X_next["trips_roll_mean_24h"].iloc[0]) if not pd.isna(X_next["trips_roll_mean_24h"].iloc[0]) else None,
})
# update historique avec la prédiction
hist = pd.concat([hist, pd.Series([y_hat])], ignore_index=True)
hist = hist.tail(168) # garder max 168h
return pd.DataFrame(preds), y_true_t0
pu = 237 # tu mets une zone
ts = "2024-07-05 18:00:00" # tu mets une date/heure existante
forecast_df, y_true_t0 = simulate_zone_forecast_walkforward(
df_model=df_model,
model=rf,
X_columns=X_train.columns,
pu_location_id=pu,
start_ts=ts,
horizon=24
)
y_true_t0, forecast_df
(163.0,
forecast_for pu_location_id pred_trips used_lag_1h used_lag_24h \
0 2024-07-05 19:00:00 237 155.331634 163.000000 97.0
1 2024-07-05 20:00:00 237 135.194161 155.331634 82.0
2 2024-07-05 21:00:00 237 122.112945 135.194161 72.0
3 2024-07-05 22:00:00 237 95.154547 122.112945 80.0
4 2024-07-05 23:00:00 237 71.640927 95.154547 42.0
5 2024-07-06 00:00:00 237 48.417043 71.640927 43.0
6 2024-07-06 01:00:00 237 29.749044 48.417043 16.0
7 2024-07-06 02:00:00 237 14.740655 29.749044 3.0
8 2024-07-06 03:00:00 237 7.712255 14.740655 2.0
9 2024-07-06 04:00:00 237 6.008172 7.712255 3.0
10 2024-07-06 05:00:00 237 5.849520 6.008172 5.0
11 2024-07-06 06:00:00 237 10.628500 5.849520 10.0
12 2024-07-06 07:00:00 237 19.451888 10.628500 28.0
13 2024-07-06 08:00:00 237 42.179179 19.451888 72.0
14 2024-07-06 09:00:00 237 65.190020 42.179179 80.0
15 2024-07-06 10:00:00 237 96.089547 65.190020 108.0
16 2024-07-06 11:00:00 237 128.687549 96.089547 175.0
17 2024-07-06 12:00:00 237 149.872694 128.687549 196.0
18 2024-07-06 13:00:00 237 164.386619 149.872694 192.0
19 2024-07-06 14:00:00 237 180.989126 164.386619 203.0
20 2024-07-06 15:00:00 237 187.273025 180.989126 198.0
21 2024-07-06 16:00:00 237 179.578330 187.273025 194.0
22 2024-07-06 17:00:00 237 186.537123 179.578330 179.0
23 2024-07-06 18:00:00 237 190.761476 186.537123 163.0
used_roll_24h
0 93.458333
1 95.888818
2 98.105241
3 100.193281
4 100.824720
5 102.059759
6 102.285469
7 102.858346
8 103.347540
9 103.585550
10 103.710891
11 103.746288
12 103.772475
13 103.416304
14 102.173770
15 101.556687
16 101.060418
17 99.130733
18 97.208762
19 96.058204
20 95.141084
21 94.694127
22 94.093224
23 94.407271 )
plt.figure(figsize=(8,4))
plt.plot(forecast_df["forecast_for"], forecast_df["pred_trips"], marker="o", color="gold")
plt.title(f"Forecast - zone {pu} (next hours)")
plt.xlabel("Time")
plt.ylabel("Predicted trips")
plt.xticks(rotation=45)
plt.grid(True)
plt.tight_layout()
plt.show()
EN/ A 24-hour walk-forward simulation was performed for a selected pickup zone.
The model captures realistic intraday demand patterns, with a nocturnal decline, morning recovery, and sustained daytime demand.
Predictions remain stable and coherent over time, indicating that the model has learned meaningful temporal dynamics rather than merely replicating the last observation.
FR/ Une simulation de prévision sur 24 heures a été réalisée pour une zone donnée.
Le modèle reproduit des dynamiques réalistes de demande, avec une baisse nocturne, une reprise matinale et une demande soutenue en journée.
La stabilité et la cohérence des prévisions confirment que le modèle a appris des schémas temporels pertinents.
Step 7 : Streamlit
#bundle model pour démo
import joblib
bundle = {
"model": rf,
"feature_columns": list(X_train.columns)
}
joblib.dump(bundle, "model_bundle.pkl")
['model_bundle.pkl']
#dataset léger pour Streamlit (on ne veut pas tout le df_model mais juste ce qu'il faut)
borough_cols = [c for c in df_model.columns if c.startswith("borough_")]
df_app = df_model[["hour_ts", "pu_location_id", "trips"] + borough_cols].copy()
df_app["hour_ts"] = pd.to_datetime(df_app["hour_ts"])
df_app.to_parquet("df_app.parquet", index=False)
déployer l'app sur stremlit cloud¶
(dans le même dossier que les 2 fichiers précédents mettre requirement.txt)
EN/ For deployment, a lightweight version of the model is used to reduce artifact size and improve app responsiveness.
FR/ Pour le déploiement de l’application, une version allégée du modèle est utilisée afin de réduire la taille et améliorer la rapidité de l’app.
# Modèle léger uniquement pour la démo Streamlit (déploiement)
rf_demo = RandomForestRegressor(
n_estimators=30,
max_depth=10,
random_state=42,
n_jobs=-1
)
rf_demo.fit(X_train, y_train)
bundle_demo = {
"model": rf_demo,
"feature_columns": list(X_train.columns)
}
joblib.dump(bundle_demo, "model_bundle_demo.pkl")
['model_bundle_demo.pkl']
cf app.py