
Introduction¶
1. Contexte et problématique métier¶
Dans le secteur de la vente automobile, et particulièrement pour les concessionnaires achetant des véhicules aux enchères, l’estimation du prix de revente est critique. L’objectif est de déterminer un prix d’achat maximal qui garantisse une marge bénéficiaire.
Cependant, se baser sur une simple estimation moyenne (le prix “moyen” du marché) est une stratégie risquée. Si le concessionnaire achète un véhicule à son prix moyen estimé, il court le risque de perdre de l’argent dans 50 % des cas (si la voiture se revend finalement moins cher).
L’objectif de ce projet est de sécuriser les achats en passant d’une prédiction ponctuelle à une quantification d’incertitude. Le concessionnaire ne cherche pas le prix “juste”, mais un prix plancher : “Je veux être sûr à 90 % de pouvoir revendre ce véhicule au moins X €”.
2. Présentation des données¶
Pour cette étude, nous utilisons le jeu de données Car Prices Dataset (disponible sur Kaggle). Il s’agit d’un registre de voitures d’occasion contenant des caractéristiques techniques et commerciales telles que :
La marque et le modèle.
L’année de fabrication et le kilométrage.
Le type de carburant et l’état général.
La tâche est une régression visant à prédire le prix de vente (variable continue). Ce jeu de données est idéal pour la quantification d’incertitude car il présente une forte hétéroscédasticité : la variance du prix n’est pas constante.
3. Approche méthodologique : Régression quantile et prédiction conforme¶
Les modèles de régression classiques minimisent l’erreur moyenne. Ils ne répondent pas à notre besoin de garantie.
Pour pallier ce manque, nous intégrons plusieurs méthodes issues de la prédiction conforme. Ces méthodes transforment la prédiction simple en intervalles de prédictions avec une garantie statistique de fiabilité.
Cette approche permet au métier de fixer ses prix d’achat sur des garanties statistiques (ex: “Je suis sûr à 80% de revendre au moins 14 000€”) plutôt que sur des intuitions.
4. Applicabilité de la prédiction conforme¶
Pour bénéficier des garanties théoriques de la prédiction conforme, il faut que les données respectent l’hypothèse d’échangeabilité. Dans le contexte du marché automobile, cette hypothèse est raisonnable car les véhicules sont vendus dans différentes régions et conditions, et les données sont réparties aléatoirement entre les ensembles d’entraînement, de calibration et de test.
De plus, ce registre a été collecté dans un intervalle de temps réduit, ce qui permet en pratique de valider l’hypothèse d’échangeabilité nécessaire aux méthodes de prédiction conforme.
import polars as pl
from kagglehub import KaggleDatasetAdapter, dataset_load
# Polars display options
pl.Config.set_tbl_hide_dataframe_shape(True)
pl.Config.set_float_precision(2)
df: pl.DataFrame = dataset_load(
adapter=KaggleDatasetAdapter.POLARS,
handle="sidharth178/car-prices-dataset",
path="train.csv",
).collect()
df.head()Nettoyage des données¶
df = df.drop(["ID"])
# Extract features and cleaning
df = df.with_columns(
# Create Turbo binary feature
pl.col("Engine volume").str.contains("Turbo").alias("Turbo"),
# Parse Engine volume: extract first number (e.g., '2.5 Turbo' -> 2.5)
pl.col("Engine volume").str.extract(r"(\d+\.?\d*)", 1).cast(pl.Float64),
# Parse Mileage: remove 'km' and convert to int
pl.col("Mileage").str.replace(" km", "").cast(pl.Int64),
# Cast Levy to numeric ('-' becomes null)
pl.col("Levy").cast(pl.Int64, strict=False),
# Convert Leather interior to binary
(pl.col("Leather interior") == "Yes").cast(pl.Boolean),
# Parse Doors: extract first number
pl.col("Doors").str.extract(r"(\d+)", 1).cast(pl.Int64),
)
# Rename columns
df = df.rename(
{
"Engine volume": "Engine volume (L)",
"Mileage": "Mileage (km)",
"Prod. year": "Production year",
"Levy": "Levy tax",
"Manufacturer": "Brand",
}
)
df.head()Statistiques descriptives¶
df.describe()Il y a des valeurs aberrantes dans les données pour le prix et le kilométrage.
Nettoyage des données aberrantes¶
df.sort("Price", descending=True).head(3)df.sort("Mileage (km)", descending=True).head(8)# On supprime les valeurs aberrantes
condition = (pl.col("Price") < 1_000_000) & (pl.col("Mileage (km)") < 1_000_000)
df = df.filter(condition)Distribution des variables numériques¶
import altair as alt
alt.data_transformers.enable("vegafusion")
numerical_cols = [
"Production year",
"Mileage (km)",
"Price",
"Levy tax",
"Engine volume (L)",
]
for col in numerical_cols:
alt.Chart(df).mark_boxplot(outliers={"size": 5}).encode(
alt.X(f"{col}:Q").scale(zero=False),
).properties(title=f"Distribution of {col}").show()Distribution des variables catégorielles et booléennes¶
low_card_categorical = df.select([s for s in df if s.n_unique() < 50]).columns
for col in low_card_categorical:
alt.Chart(df).mark_bar().encode(
alt.X("count()"),
alt.Y(f"{col}:N", sort="-x", title=None),
tooltip=[f"{col}:N", "count()"],
).properties(
title=f"Distribution of {col}",
).show()Matrice de corrélation¶
On observe une faible corrélation entre le prix de la voiture et l’année de production, le kilométrage et la présence d’un turbo.
from utils import plot_correlation
plot_correlation(df)Évolution des prix par année de production¶
alt.Chart(df).mark_bar().encode(
alt.X("Production year:T"),
alt.Y("mean(Price):Q", title=None),
tooltip=["Production year:T", "mean(Price):Q"],
).properties(title="Average Price by Production Year")Relation entre prix et année de production¶
alt.Chart(df).mark_rect(clip=True).encode(
alt.X("Production year:Q").bin(maxbins=50),
alt.Y("Price:Q").bin(maxbins=200).scale(domain=[0, 200_000]),
alt.Color("count()").scale(type="log"),
tooltip=["count()"],
).properties(title="Price vs Production year (Density Plot)")Relation entre prix et kilométrage¶
alt.Chart(df).mark_rect(clip=True).encode(
alt.X("Mileage (km):Q").bin(maxbins=50).scale(domain=[0, 500_000]),
alt.Y("Price:Q").bin(maxbins=100).scale(domain=[0, 200_000]),
alt.Color("count()").scale(type="log"),
tooltip=["count()"],
).properties(title="Price vs Mileage (Density Plot)")Distribution des prix par type de carburant¶
alt.Chart(df).mark_boxplot(extent="min-max", clip=True).encode(
alt.X("Price:Q").scale(domain=[0, 50_000]),
alt.Y("Fuel type:N", title=None),
alt.Color("Fuel type:N", legend=None),
).properties(title="Price Distribution by Fuel Type")Distribution des prix par catégorie de véhicule¶
alt.Chart(df).mark_boxplot(extent="min-max", clip=True).encode(
alt.X("Price:Q").scale(domain=[0, 50_000]),
alt.Y("Category:N", title=None),
alt.Color("Category:N", legend=None),
).properties(title="Price Distribution by Vehicle Category")Distribution des prix par top 10 marque¶
# Get top 10 brands by count
top_brands = df["Brand"].value_counts(sort=True).head(10)["Brand"].to_list()
df_top_brands = df.filter(pl.col("Brand").is_in(top_brands))
alt.Chart(df_top_brands).mark_boxplot(extent="min-max", clip=True).encode(
alt.X("Price:Q").scale(domain=[0, 50_000]),
alt.Y("Brand:N", title=None, sort="-x"),
alt.Color("Brand:N", legend=None),
).properties(title="Price Distribution by Top 10 Brands")On constate une hétéroscédasticité des prix en fonction du kilométrage des voitures, des marques, des catégories de véhicules et des types de carburant.
Conversion en données catégorielles¶
Utile plus tard pour la gestion automatique des variables catégorielles dans les modèles.
categorical_features = [
"Brand",
"Category",
"Fuel type",
"Gear box type",
"Drive wheels",
"Wheel",
"Color",
]
# Cast to categorical dtypes
df = df.cast(dict.fromkeys(categorical_features, pl.Categorical))Sauvegarde des données nettoyées¶
# Save preprocessed data for modeling
df.write_parquet("../../data/car_prices_clean.parquet")