Premiers pas avec DVF : le prix au mètre carré de votre commune
Objectif
Partir des fichiers bruts des Demandes de valeurs foncières et obtenir, pour chaque commune d’un département, le prix médian au mètre carré des appartements vendus, année par année. En chemin, on rencontre les pièges habituels d’un vrai jeu de données : une ligne n’est pas une vente, les prix extrêmes faussent les moyennes, et il faut décider ce que l’on garde.
Ce tutoriel est un notebook exécuté tel quel : les résultats affichés sont ceux obtenus sur les fichiers publics au moment de l’exécution. Il suppose Python et pandas installés, et rien d’autre.
Charger les données
DVF est publié par département et par année, en CSV compressé. La version géolocalisée d’Etalab
ajoute les coordonnées de chaque bien. On charge trois années du Rhône, directement depuis leur
adresse publique : changer DEPARTEMENT suffit pour refaire l’analyse ailleurs.
import pandas as pd
pd.set_option("display.notebook_repr_html", False)
pd.set_option("display.width", 110)
pd.set_option("display.max_columns", 12)
DEPARTEMENT = "69"
ANNEES = [2023, 2024, 2025]
URL = "https://files.data.gouv.fr/geo-dvf/latest/csv/{annee}/departements/{dep}.csv.gz"
dvf = pd.concat(
[pd.read_csv(URL.format(annee=a, dep=DEPARTEMENT), low_memory=False) for a in ANNEES],
ignore_index=True,
)
dvf.shape
(245873, 40)
Comprendre la structure
Chaque ligne est un lot ou un local concerné par une mutation, pas une vente. Une vente
d’appartement avec une cave et un parking occupe trois lignes, qui partagent le même
id_mutation et la même valeur_fonciere, celle de l’ensemble.
colonnes = ["id_mutation", "date_mutation", "nature_mutation", "valeur_fonciere",
"type_local", "surface_reelle_bati", "nombre_pieces_principales", "nom_commune"]
dvf[colonnes].head(8)
id_mutation date_mutation nature_mutation valeur_fonciere type_local surface_reelle_bati \
0 2023-894771 2023-01-03 Vente 335000.0 Appartement 65.0
1 2023-894771 2023-01-03 Vente 335000.0 Appartement 70.0
2 2023-894772 2023-01-03 Vente terrain à bâtir 349000.0 NaN NaN
3 2023-894773 2023-01-03 Vente 296909.0 Maison 124.0
4 2023-894774 2023-01-04 Vente 200000.0 Dépendance NaN
5 2023-894774 2023-01-04 Vente 200000.0 Appartement 58.0
6 2023-894775 2023-01-04 Vente 511900.0 Maison 85.0
7 2023-894775 2023-01-04 Vente 511900.0 Maison 85.0
nombre_pieces_principales nom_commune
0 3.0 Lyon 4e Arrondissement
1 2.0 Lyon 4e Arrondissement
2 NaN Craponne
3 5.0 Orliénas
4 0.0 Lyon 2e Arrondissement
5 3.0 Lyon 2e Arrondissement
6 4.0 Craponne
7 4.0 Craponne
dvf["nature_mutation"].value_counts()
nature_mutation
Vente 224394
Vente en l'état futur d'achèvement 19020
Echange 1709
Adjudication 493
Vente terrain à bâtir 222
Expropriation 35
Name: count, dtype: int64
dvf["type_local"].value_counts(dropna=False)
type_local
Dépendance 95937
Appartement 60654
NaN 51336
Maison 21825
Local industriel. commercial ou assimilé 16121
Name: count, dtype: int64
Les dépendances (caves, garages) sont plus nombreuses que les logements, et un sixième des lignes n’ont pas de type de local : ce sont des terrains. Regardons combien de lignes compte une mutation.
dvf.groupby("id_mutation").size().value_counts().head(8)
2 33301
1 29555
3 18070
4 6164
5 2124
6 1308
7 661
8 522
Name: count, dtype: int64
Nettoyer
Trois décisions, à expliciter parce qu’elles changent le résultat :
- On ne garde que les ventes (pas les échanges, adjudications ni ventes en l’état futur d’achèvement, dont le prix n’est pas comparable).
- On ne garde que les mutations qui contiennent un seul logement. Une mutation avec deux appartements n’a qu’un prix global, impossible à répartir. Les dépendances qui accompagnent le logement restent acceptées : leur valeur est incluse dans le prix, c’est une approximation assumée.
- On ne garde que les appartements avec une surface et un prix renseignés.
logements = dvf[dvf["type_local"].isin(["Appartement", "Maison"])]
par_mutation = logements.groupby("id_mutation").size()
mutations_simples = par_mutation[par_mutation == 1].index
appts = logements[
logements["id_mutation"].isin(mutations_simples)
& (logements["nature_mutation"] == "Vente")
& (logements["type_local"] == "Appartement")
& logements["surface_reelle_bati"].ge(9)
& logements["valeur_fonciere"].gt(0)
].copy()
appts["prix_m2"] = appts["valeur_fonciere"] / appts["surface_reelle_bati"]
appts["annee"] = pd.to_datetime(appts["date_mutation"]).dt.year
len(appts)
43833
Repérer les valeurs aberrantes
Avant d’agréger, on regarde la distribution. DVF contient des ventes à 1 €, des cessions entre membres d’une famille, des erreurs de saisie et des biens d’exception. La moyenne y est très sensible ; la médiane beaucoup moins, mais autant ne pas laisser l’absurde dans le calcul.
appts["prix_m2"].describe(percentiles=[0.01, 0.05, 0.5, 0.95, 0.99]).round(0)
count 43833.0
mean 3902.0
std 3366.0
min 0.0
1% 840.0
5% 1683.0
50% 3797.0
95% 6231.0
99% 7783.0
max 361702.0
Name: prix_m2, dtype: float64
bas, haut = appts["prix_m2"].quantile([0.01, 0.99])
appts = appts[appts["prix_m2"].between(bas, haut)]
print(f"Bornes conservées : {bas:.0f} à {haut:.0f} €/m², {len(appts)} ventes")
Bornes conservées : 840 à 7783 €/m², 42955 ventes
Agréger par commune et par année
On calcule le nombre de ventes et la médiane du prix au mètre carré, puis on ne garde que les cases qui reposent sur au moins trente ventes : une médiane sur cinq transactions ne veut rien dire.
stats = (
appts.groupby(["nom_commune", "annee"])["prix_m2"]
.agg(ventes="size", prix_m2_median="median")
)
fiable = stats[stats["ventes"] >= 30]
tableau = fiable["prix_m2_median"].round(0).unstack("annee")
tableau.sort_values(2024, ascending=False).head(15)
annee 2023 2024 2025
nom_commune
Lyon 2e Arrondissement 5526.0 5314.0 4946.0
Lyon 6e Arrondissement 5713.0 5204.0 5440.0
Lyon 1er Arrondissement 5346.0 4913.0 4764.0
Lyon 4e Arrondissement 5270.0 4804.0 4787.0
Charbonnières-les-Bains 4536.0 4627.0 4626.0
Lyon 3e Arrondissement 4846.0 4503.0 4488.0
Lyon 7e Arrondissement 4797.0 4438.0 4507.0
Chaponost 4420.0 4436.0 3763.0
Genas 4726.0 4177.0 3595.0
Lyon 8e Arrondissement 4263.0 4023.0 3965.0
Écully 4312.0 4000.0 3833.0
Tassin-la-Demi-Lune 4444.0 4000.0 4120.0
Craponne 4411.0 3942.0 3981.0
Sathonay-Camp 4051.0 3849.0 3681.0
Mions 3784.0 3808.0 3956.0
Zoom sur Lyon
Les arrondissements de Lyon sont des communes à part entière dans DVF. On calcule l’évolution entre la première et la dernière année chargée.
lyon = tableau[tableau.index.str.startswith("Lyon")].copy()
lyon["evolution_%"] = ((lyon[ANNEES[-1]] / lyon[ANNEES[0]] - 1) * 100).round(1)
lyon.sort_values("evolution_%")
annee 2023 2024 2025 evolution_%
nom_commune
Lyon 1er Arrondissement 5346.0 4913.0 4764.0 -10.9
Lyon 2e Arrondissement 5526.0 5314.0 4946.0 -10.5
Lyon 4e Arrondissement 5270.0 4804.0 4787.0 -9.2
Lyon 9e Arrondissement 4088.0 3651.0 3728.0 -8.8
Lyon 5e Arrondissement 4000.0 3790.0 3688.0 -7.8
Lyon 3e Arrondissement 4846.0 4503.0 4488.0 -7.4
Lyon 8e Arrondissement 4263.0 4023.0 3965.0 -7.0
Lyon 7e Arrondissement 4797.0 4438.0 4507.0 -6.0
Lyon 6e Arrondissement 5713.0 5204.0 5440.0 -4.8
Votre commune
Il suffit de l’appeler par son nom tel qu’il apparaît dans le fichier.
tableau.loc["Villeurbanne"]
annee
2023 3840.0
2024 3505.0
2025 3566.0
Name: Villeurbanne, dtype: float64
Ce qu’il faut retenir
- Une ligne DVF n’est pas une vente. Toujours raisonner par
id_mutation. - Les filtres sont des choix. Les expliciter, les garder dans le code, et savoir ce qu’ils excluent : ici, les ventes groupées et le neuf.
- Médiane et effectif minimal protègent contre les valeurs extrêmes et les petits nombres.
- Le dernier fichier annuel peut être incomplet si l’année n’est pas terminée au moment de sa publication : vérifier la date de mise à jour sur data.gouv.fr avant de commenter une baisse.
Pour aller plus loin
- Changer
DEPARTEMENT, ou passer aux maisons en remplaçant « Appartement » par « Maison » et en ajoutant la surface du terrain à l’analyse. - Cartographier le résultat avec les contours communaux d’Admin Express (IGN).
- Construire un modèle qui estime le prix d’un bien à partir de ses caractéristiques : c’est l’objet du tutoriel Prédire le prix d’un appartement avec DVF et scikit-learn.