Le niveau de vie au carreau de 200 mètres dans la petite couronne

Auteur·rice

Lino Galiana

Ce tutoriel montre comment récupérer les données carroyées de l’Insee avec cartiflette : des carreaux de 200 mètres de côté, avec la population, les ménages, les logements et les revenus de leurs habitants. Ces carreaux ne dépendent d’aucun découpage administratif : ils révèlent les contrastes à l’intérieur même des communes. Cela nous donnera, in fine, cette carte du niveau de vie à Paris et dans la petite couronne :

NoteLes données carroyées de l’Insee

L’Insee diffuse les revenus, la pauvreté et le niveau de vie en 2021 (dispositif Filosofi) sur une grille de carreaux de 200 mètres, en France métropolitaine, en Martinique et à La Réunion. Chaque carreau porte des effectifs et des sommes : individus (ind), ménages (men), ménages pauvres (men_pauv), somme des niveaux de vie (ind_snv), logements par période de construction, logements sociaux (log_soc), individus par tranche d’âge…

Le secret statistique interdit de diffuser un carreau de moins de 11 ménages : ses valeurs sont alors imputées, estimées à partir des carreaux voisins, et la variable i_est_200 vaut 1. Ces carreaux sont nombreux à la campagne, rares en ville.

cartiflette publie ces carreaux avec leur géométrie (niveaux CARREAU_200M et CARREAU_1KM) et les rattache chacun à la commune qui contient son centre, avec les zonages de cette commune. On peut donc les demander par département, par commune, par intercommunalité… comme les autres niveaux. Il faut la version 0.4.0 du client.

1 Récupérer les carreaux

On demande les carreaux de 200 mètres (borders="CARREAU_200M") de Paris et des trois départements de la petite couronne :

from cartiflette import carti_download

carreaux = carti_download(
    values=["75", "92", "93", "94"],
    borders="CARREAU_200M",
    filter_by="DEPARTEMENT",
    year=2026,
)
1
Millésime des communes auxquelles les carreaux sont rattachés. Les données sont celles de Filosofi 2021, la dernière édition carroyée.

On obtient 13 752 carreaux, où vivent 6 442 101 personnes. Chacun garde les variables de l’Insee et reçoit la commune qui contient son centre (INSEE_COM, INSEE_COG, NOM_COM) avec ses zonages :

idcar_200m ind men ind_snv i_est_200 lcog_geo NOM_COM
0 CRS3035RES200mN2889400E3760000 112.5 65.0 6468567.7 0 75101,75106 Paris 1er Arrondissement
1 CRS3035RES200mN2889600E3760000 124.5 53.0 4530188.8 0 75101 Paris 1er Arrondissement
2 CRS3035RES200mN2889600E3760200 511.0 308.0 18666999.4 0 75101 Paris 1er Arrondissement

lcog_geo liste les communes que recouvre le carreau. Seuls 12 % des carreaux ont des valeurs imputées, et ils ne regroupent que 0,6 % de la population.

2 Calculer les indicateurs

Les carreaux donnent des sommes et des effectifs, pas des moyennes : on les calcule. Le niveau de vie moyen rapporte la somme des niveaux de vie au nombre d’individus ; la part des ménages pauvres, le nombre de ménages pauvres au nombre de ménages ; la part des logements sociaux, leur nombre à celui de tous les logements.

logements = carreaux[["log_av45", "log_45_70", "log_70_90", "log_ap90", "log_inc"]].sum(axis=1)

carreaux["niveau_vie"] = carreaux["ind_snv"] / carreaux["ind"]
carreaux["pauvrete"] = 100 * carreaux["men_pauv"] / carreaux["men"]
carreaux["logements_sociaux"] = 100 * carreaux["log_soc"] / logements
1
Une moyenne, et non une médiane comme pour les IRIS : la somme des niveaux de vie est diffusée, pas leur distribution. Les niveaux de vie extrêmes sont ramenés à des bornes (winsorisés) avant d’être sommés.

Pour la carte, les communes et arrondissements des quatre départements servent de fond, et leurs limites donnent celles des départements :

communes = carti_download(
    values=["75", "92", "93", "94"],
    borders="COMMUNE_ARRONDISSEMENT",
    filter_by="DEPARTEMENT",
    year=2026,
)
departements = communes.dissolve("INSEE_DEP")

3 Faire la carte

Les couleurs vont des niveaux de vie les plus faibles, en clair, aux plus élevés, en foncé ; chaque classe regroupe un sixième des carreaux. Les deux autres indicateurs, en pourcentage, ont des classes fixes : beaucoup de carreaux n’ont aucun logement social. On peut changer d’indicateur et zoomer sur un département :

À 200 mètres, les contrastes ne s’arrêtent pas aux limites des communes : l’ouest de Paris et les communes qui le prolongent dans les Hauts-de-Seine ont les niveaux de vie les plus élevés, le nord-est de Paris et la Seine-Saint-Denis les plus faibles, mais chaque commune mêle des quartiers aisés et modestes. Les logements sociaux dessinent des îlots, souvent les carreaux où la pauvreté est la plus forte.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 9))
communes.plot(ax=ax, color="#e0e0e0", edgecolor="white", linewidth=0.3)
carreaux.plot(
    ax=ax,
    column="niveau_vie",
    scheme="Quantiles",
    k=6,
    cmap="YlOrBr",
    linewidth=0,
    legend=True,
    legend_kwds={
        "title": "Niveau de vie moyen (€ par an)",
        "loc": "upper center",
        "bbox_to_anchor": (0.5, 0.02),
        "ncol": 3,
        "fmt": "{:.0f}",
        "frameon": False,
    },
)
communes.boundary.plot(ax=ax, color="white", linewidth=0.2)
departements.boundary.plot(ax=ax, color="black", linewidth=0.6)
for texte in ax.get_legend().get_texts():
    bornes = texte.get_text().split(", ")
    texte.set_text(" – ".join(f"{int(b):,}".replace(",", " ") for b in bornes))
ax.set_axis_off()
plt.figtext(
    0.5,
    0.02,
    "Sources : Insee, Filosofi 2021 (données carroyées) ; IGN, ADMIN EXPRESS COG CARTO 2026",
    horizontalalignment="center",
    fontsize=8,
    style="italic",
)
plt.show()
1
Bornes des classes à la française : « 22 300 – 26 900 » plutôt que « 22300, 26900 ».

Niveau de vie moyen par carreau de 200 mètres, Paris et petite couronne

4 Agréger les carreaux par commune

Chaque carreau n’est rattaché qu’à une commune : additionner les carreaux d’une commune ne compte donc personne deux fois. On additionne les sommes et les effectifs, puis on refait le rapport, plutôt que de faire la moyenne des niveaux de vie des carreaux, qui donnerait le même poids à un carreau de 10 habitants et à un carreau de 3 000 :

par_commune = (
    carreaux.groupby(["INSEE_COG", "NOM_COM"])[["ind", "ind_snv"]]
    .sum()
    .assign(niveau_vie=lambda d: d["ind_snv"] / d["ind"])
    .sort_values("niveau_vie")
)
Commune Habitants Niveau de vie moyen (€)
La Courneuve 43 533 16 509
Clichy-sous-Bois 24 128 17 262
Stains 37 450 17 426
Aubervilliers 77 747 17 437
Bobigny 51 839 17 819
Paris 16e Arrondissement 157 086 48 088
Paris 6e Arrondissement 32 265 48 490
Neuilly-sur-Seine 56 886 49 347
Paris 8e Arrondissement 37 169 50 056
Paris 7e Arrondissement 45 873 51 549

Les cinq niveaux de vie moyens les plus faibles sont tous en Seine-Saint-Denis, les cinq plus élevés à Paris (6e, 7e, 8e et 16e arrondissements) et à Neuilly-sur-Seine. Un carreau à cheval sur deux communes est compté entier dans celle de son centre : l’écart avec les chiffres communaux de l’Insee reste faible pour une commune entière, mais il vaut mieux préférer ces derniers quand ils existent. Les carreaux servent surtout à ce qu’aucun découpage ne montre : les écarts à l’intérieur d’une commune, ou des zones d’étude sur mesure (un rayon autour d’une gare avec where, comme dans le tutoriel sur les filtres).

Pour comparer avec le revenu médian des IRIS et les quartiers prioritaires, voir le tutoriel sur les IRIS.

5 Les cartes en Javascript

Les cartes de cette page sont faites avec Observable Plot, par le biais de Quarto. Le code source de cette page est disponible ici.

Plus de 13 000 carrés pèseraient plusieurs mégaoctets en GeoJSON. Or un carreau se déduit de son identifiant : CRS3035RES200mN2894200E3763400 est le carré de 200 mètres dont le coin inférieur gauche est à 2 894 200 mètres au nord et 3 763 400 mètres à l’est, dans la projection européenne LAEA (EPSG:3035). On ne transmet donc à Javascript que ces deux nombres et les indicateurs, colonne par colonne, et le fond des communes dans la même projection :

import json

import pandas as pd


def colonne(serie, decimales=0):
    """Liste JSON, valeurs manquantes en null."""
    return [None if pd.isna(v) else round(float(v), decimales) for v in serie]


coins = carreaux["idcar_200m"].str.extract(r"N(?P<nord>\d+)E(?P<est>\d+)$").astype(int)

ojs_define(
    carreaux_col={
        "nord": coins["nord"].tolist(),
        "est": coins["est"].tolist(),
        "commune": carreaux["NOM_COM"].tolist(),
        "departement": carreaux["INSEE_DEP"].tolist(),
        "ind": colonne(carreaux["ind"]),
        "niveau_vie": colonne(carreaux["niveau_vie"], -1),
        "pauvrete": colonne(carreaux["pauvrete"], 1),
        "logements_sociaux": colonne(carreaux["logements_sociaux"], 1),
        "impute": carreaux["i_est_200"].tolist(),
    },
    communes_3035=json.loads(
        communes[["INSEE_COG", "INSEE_DEP", "geometry"]].to_crs(3035).to_json(drop_id=True)
    ),
    departements_3035=json.loads(
        departements.reset_index()[["INSEE_DEP", "geometry"]].to_crs(3035).to_json(drop_id=True)
    ),
)
1
Les communes et les départements passent dans la même projection que les carreaux. Dans un plan (et non sur la sphère), le sens de parcours des polygones n’a pas d’importance pour d3.

Côté Javascript, on reconstruit les carrés, puis on les dessine dans le plan de la projection LAEA, sans autre projection (reflect-y : le nord en haut) :

carreauxGeo = d3.range(carreaux_col.nord.length).map((i) => {
    const [x, y] = [carreaux_col.est[i], carreaux_col.nord[i]]
    return {
        type: "Feature",
        properties: Object.fromEntries(Object.keys(carreaux_col).map((k) => [k, carreaux_col[k][i]])),
        geometry: {type: "Polygon", coordinates: [[[x, y], [x + 200, y], [x + 200, y + 200], [x, y + 200], [x, y]]]}
    }
})

collection = (features) => ({type: "FeatureCollection", features})
nombre = (x) => Math.round(x).toLocaleString("fr-FR")

// Six classes de même effectif, sur tous les carreaux : les couleurs ne
// changent pas quand on zoome sur un département
function quantiles(champ) {
    const valeurs = carreauxGeo.map((d) => d.properties[champ]).filter((v) => v !== null).sort(d3.ascending)
    return d3.range(1, 6).map((i) => Math.round(d3.quantileSorted(valeurs, i / 6) / 100) * 100)
}

// Les pourcentages ont des classes fixes : beaucoup de carreaux sont à 0 %
// de logements sociaux, ce qui écraserait des quantiles
indicateurs = new Map([
    ["Niveau de vie moyen", {champ: "niveau_vie", libelle: "Niveau de vie moyen (€ par an)", schema: "ylorbr", unite: " €", seuils: quantiles("niveau_vie")}],
    ["Ménages pauvres", {champ: "pauvrete", libelle: "Part des ménages pauvres (%)", schema: "purples", unite: " %", seuils: [5, 10, 15, 25, 35]}],
    ["Logements sociaux", {champ: "logements_sociaux", libelle: "Part des logements sociaux (%)", schema: "blues", unite: " %", seuils: [5, 15, 30, 50, 75]}]
])

infobulle = (d, {champ, libelle, unite}) => `${d.properties.commune}
${libelle} : ${d.properties[champ] === null ? "non calculable" : nombre(d.properties[champ]) + unite}
${nombre(d.properties.ind)} habitants${d.properties.impute ? " (valeurs imputées)" : ""}`

La carte : le fond des communes, les carreaux colorés selon l’indicateur (avec un contour de leur couleur, sans quoi un fin quadrillage apparaît entre carreaux voisins), les limites des communes en blanc et celles des départements :

function carteCarreaux(indicateur, zone) {
    const dans = (d) => zone === null || d.properties.departement === zone || d.properties.INSEE_DEP === zone
    const cellules = carreauxGeo.filter(dans)
    const fond = communes_3035.features.filter(dans)
    const limites = departements_3035.features.filter(dans)
    const couleur = (d) => d.properties[indicateur.champ]
    return Plot.plot({
        projection: {type: "reflect-y", domain: collection(fond)},
        width: 720,
        color: {
            type: "threshold",
            domain: indicateur.seuils,
            scheme: indicateur.schema,
            legend: true,
            label: indicateur.libelle,
            tickFormat: nombre,
            width: 480
        },
        marks: [
            Plot.geo(fond, {fill: "var(--carte-vide)", stroke: "var(--bs-body-bg)", strokeWidth: 0.5}),
            Plot.geo(cellules.filter((d) => couleur(d) !== null), Plot.centroid({
                fill: couleur,
                stroke: couleur,
                strokeWidth: 0.3,
                title: (d) => infobulle(d, indicateur),
                tip: true
            })),
            Plot.geo(fond, {stroke: "var(--bs-body-bg)", strokeWidth: zone === null ? 0.4 : 0.8, strokeOpacity: 0.8}),
            Plot.geo(limites, {stroke: "var(--bs-body-color)", strokeWidth: 0.9})
        ]
    })
}

6 Sources