Aller au contenu

Trouvez les lieux photographiés de Rennes

Quels sont les lieux de Rennes qui attirent les photographes ? Pour le savoir, notre TP partait d’un fichier de photos publiées sur Flickr et géolocalisées autour de la ville. Un point d’intérêt (POI, point of interest) y était défini comme un lieu photographié par un grand nombre d’utilisateurs distincts. Il fallait donc trouver les zones denses de photos, puis les décrire avec les mots-clés que les photographes leur ont attribués.

Ce chapitre refait tout le parcours, en réutilisant les outils du cours : exploration et nettoyage (partie 1), DBSCAN (chapitre 8) et itemsets fréquents (chapitre 9). Les chiffres sont ceux des sorties de notre notebook. Nous terminerons par une analyse critique, car le résultat, vous allez le voir, n’est pas tout à fait celui qu’on espérait.

Le fichier flickrRennes.csv contient une ligne par photo, avec :

  • id_photo, un identifiant censé être unique ;
  • title, le titre, et tags, les mots-clés libres saisis par le photographe ;
  • id_photographer, l’identifiant de l’utilisateur qui a publié la photo ;
  • lat et long, les coordonnées de la prise de vue, en degrés ;
  • url, le lien vers la photo ;
  • la date de publication et la date de prise de vue, chacune déclinée en année, mois, jour, heure et minute.

La démarche suit quatre étapes : explorer, nettoyer, regrouper les photos par zones denses, puis caractériser chaque zone par ses tags.

29 541lignes4 148sans doublons1 2321 photo / heure20 groupes+ bruit (DBSCAN)9 étiquetés(Apriori)

Le fichier compte 29 541 lignes. Premier contrôle de cohérence : la latitude et la longitude moyennes valent environ 48,1086 et −1,6809, et les médianes 48,1101 et −1,6782, très proches du centre de Rennes (48,1173 ; −1,6778). Les photos sont bien autour de la ville. Les prises de vue s’étalent de 2004 à 2019, et le fichier compte 213 photographes distincts.

import pandas as pd
photos = pd.read_csv("flickrRennes.csv")
print(len(photos)) # 29541
print(photos[["lat", "long"]].agg(["mean", "median"]))
print(photos["date_taken_year"].agg(["min", "max"])) # 2004 et 2019
print(photos["id_photographer"].nunique()) # 213
print(photos["id_photo"].nunique()) # 4148 : alerte !

La dernière ligne sonne l’alarme : seulement 4 148 identifiants distincts pour 29 541 lignes. Chaque photo apparaît en moyenne un peu plus de sept fois. Les copies étant strictement identiques, drop_duplicates() ramène le fichier à 4 148 lignes. Sans ce contrôle, toutes les densités calculées ensuite auraient été gonflées, de façon inégale selon les photos.

L’exploration de la répartition montre ensuite une activité très inégale : 74 photographes n’ont publié qu’une seule photo, tandis que d’autres en ont publié des dizaines. Le TP trace cette distribution (nombre de photographes selon le nombre de photos publiées) en échelle logarithmique, ainsi que la répartition des photos par mois de prise de vue. Retenez surtout l’inégalité : quelques utilisateurs très actifs pèsent lourd dans les données.

Voici le piège central du sujet. Un utilisateur qui photographie son chat vingt fois dans son salon, en quelques minutes, crée une zone très dense de photos… qui n’est évidemment pas un lieu d’intérêt. Rappelons la définition : un POI est photographié par beaucoup de personnes différentes, pas beaucoup de fois.

Le TP proposait une règle volontairement simple : ne garder qu’une photo par utilisateur et par heure de prise de vue.

photos = photos.drop_duplicates()
cles = ["id_photographer", "date_taken_year", "date_taken_month",
"date_taken_day", "date_taken_hour"]
photos = photos.drop_duplicates(subset=cles) # une photo par photographe et par heure
print(len(photos)) # 1232
monument : 9 photos,9 photographeschat du salon : 25 photos,1 photographeavant : la zone la plus dense est… un salonmonument : 9 photos,9 photographeschat du salon : 1 photo,1 photographeaprès « une photo par photographe et par heure »
Schéma, pas les données du TP. Avant la règle, la zone la plus dense est l'album d'un seul utilisateur ; après, elle se réduit à une photo, et le monument, vu par neuf personnes, ressort.

Il reste 1 232 photos, soit 30 % des photos distinctes. Cette règle a deux défauts évidents. Le découpage horaire est arbitraire : deux photos prises à 13 h 59 et 14 h 01 au même endroit comptent deux fois, puisqu’elles tombent dans deux heures différentes. Et un utilisateur qui passe la journée au même endroit compte encore une fois par heure. Nous verrons que ces défauts laissent des traces.

photographe 1photographe 213 h14 h15 h16 h17 h13 h 59 et 14 h 01 : même instant, gardées deux fois12 photos au même endroit : 4 gardées, une par heure entamée
Le découpage horaire compte deux fois deux photos prises à deux minutes d'écart, et compte encore quatre fois quelqu'un resté tout l'après-midi au même endroit.

Pourquoi DBSCAN plutôt que k-means ? Tous les arguments du chapitre 8 s’appliquent : on ignore combien il y a de lieux d’intérêt ; un lieu peut avoir une forme allongée (des quais, une rue commerçante) ; et surtout, beaucoup de photos sont isolées, prises au hasard d’une promenade, et doivent rester hors des groupes. k-means les rattacherait de force à un lieu.

Le TP appliquait DBSCAN directement sur la latitude et la longitude, avec les paramètres conseillés eps = 0,0003 et min_samples = 7 :

from sklearn.cluster import DBSCAN
dbscan = DBSCAN(eps=0.0003, min_samples=7).fit(photos[["lat", "long"]])
photos["cluster"] = dbscan.labels_
print(sorted(photos["cluster"].unique())) # de -1 à 19

On obtient 20 groupes, numérotés de 0 à 19, plus le bruit (étiquette −1) pour toutes les photos hors des zones denses. Rappelons ce que signifie eps ici : 0,0003 degré représente environ 33 m du nord au sud, mais seulement 22 m d’est en ouest à la latitude de Rennes. Le voisinage est une petite ellipse de quelques dizaines de mètres, et il faut au moins 7 photos (après la règle d’une par heure) pour former un noyau dense.

On affiche ensuite les photos sur une carte interactive avec folium, une couleur par groupe (gris clair pour le bruit), et un marqueur au barycentre de chaque groupe :

import folium
carte = folium.Map(location=[48.117266, -1.6777926], tiles="cartodbpositron", zoom_start=12)
for _, p in photos.iterrows():
couleur = "lightgray" if p["cluster"] == -1 else "blue" # une couleur par groupe en pratique
folium.CircleMarker([p["lat"], p["long"]], radius=1, color=couleur).add_to(carte)
for c, groupe in photos[photos["cluster"] >= 0].groupby("cluster"):
folium.Marker([groupe["lat"].mean(), groupe["long"].mean()], popup=str(c)).add_to(carte)

Un contrôle simple jette pourtant un premier doute. En listant les URL des photos du groupe 2, on en trouve 43, et toutes pointent vers le même photographe. Un « lieu d’intérêt » construit par une seule personne ne correspond pas à la définition.

Pour savoir ce que représente chaque groupe, on se tourne vers les tags. Encore faut-il les nettoyer, car ils sont saisis librement. Le nettoyage appliqué à chaque photo enchaîne cinq opérations :

  1. tout mettre en minuscules ;
  2. supprimer les accents (« marché » devient « marche ») ;
  3. retirer les mots vides du français (« le », « de », « à »…), avec la liste fournie par nltk ;
  4. retirer les identifiants d’appareil photo, comme img_1234 ou dsc_0042 ;
  5. ne garder que les tags composés de lettres, chiffres, tirets et tirets bas (expression régulière ^[\w-]+$), ce qui élimine les tags contenant d’autres caractères spéciaux.
import re
from nltk.corpus import stopwords
MOTS_VIDES = set(stopwords.words("french"))
ACCENTS = str.maketrans("àâäáãéèêëîïôöùûü", "aaaaaeeeeiioouuu")
TAG_VALIDE = re.compile(r"^[\w-]+$")
def nettoyer(tags):
propres = []
for tag in str(tags).lower().translate(ACCENTS).split():
if tag in MOTS_VIDES or tag.startswith(("img_", "dsc_")) or not TAG_VALIDE.match(tag):
continue
propres.append(tag)
return propres
photos["tags_propres"] = photos["tags"].fillna("").apply(nettoyer)

Ensuite, pour chaque groupe, les tags de chaque photo forment une transaction (chapitre 9). On extrait les itemsets fréquents avec Apriori et un support minimal de 0,4 : un itemset doit apparaître sur au moins 40 % des photos du groupe. On ne garde que les itemsets d’au moins deux tags, on les trie par support puis par longueur, et le premier devient l’étiquette du groupe, affichée dans le marqueur de la carte.

from mlxtend.frequent_patterns import apriori
from mlxtend.preprocessing import TransactionEncoder
def etiqueter(groupe):
transactions = groupe["tags_propres"].tolist()
enc = TransactionEncoder()
tableau = pd.DataFrame(enc.fit(transactions).transform(transactions), columns=enc.columns_)
freq = apriori(tableau, min_support=0.4, use_colnames=True)
freq["longueur"] = freq["itemsets"].apply(len)
freq = freq[freq["longueur"] >= 2].sort_values(["support", "longueur"], ascending=False)
return "" if freq.empty else ", ".join(freq["itemsets"].iloc[0])
etiquettes = {c: etiqueter(g) for c, g in photos[photos["cluster"] >= 0].groupby("cluster")}

Voici ce que donne notre notebook :

Groupe(s)Étiquette obtenueLecture
10jacobins, couventle couvent des Jacobins, en centre-ville : un vrai lieu d’intérêt
8frac, bretagne, rennesle FRAC Bretagne (Fonds régional d’art contemporain) : un vrai lieu
12rns, spotting, rennesl’observation d’avions (spotting) ; RNS est le code de l’aéroport de Rennes
2, 3, 6, 14une douzaine de tags d’observation d’avions (spotting, aviation, avion, aircraft, aeronautique, plane…) et le pseudonyme d’un photographeles photos d’un même passionné d’aviation
0le nom d’un studio photo et celui de son auteurla production d’un studio professionnel
11instagram, iphoneography, square, app, formatdes tags ajoutés automatiquement par une application mobile
les 11 autresaucunepas d’itemset d’au moins deux tags avec un support de 0,4

Le bilan est mitigé, et c’est ce qui rend l’étude de cas instructive. Sur 20 groupes, 11 n’ont aucune étiquette, et parmi les 9 étiquetés, seuls quelques-uns (les Jacobins, le FRAC) désignent clairement un lieu. Plusieurs étiquettes trahissent au contraire un seul auteur : un studio photo, un passionné d’avions dont le pseudonyme étiquette quatre groupes à lui seul, une application qui ajoute ses propres tags. Le groupe 2, avec ses 43 photos d’un même photographe, en est la preuve directe.

108122361401114579131516171819vrai lieu (Jacobins, FRAC)observation d’avions à l’aéroport (RNS)un seul auteur (passionné d’avions, studio)tags d’une application mobileaucune étiquette (11 groupes)
Sur 20 groupes, deux seulement désignent clairement un lieu d'intérêt ; cinq trahissent un seul auteur et onze n'ont aucune étiquette.

Que retenir, et que faudrait-il améliorer ?

  • Compter des personnes, pas des photos. La règle « une photo par heure » ne suffit pas à respecter la définition d’un POI. Il faudrait exiger un nombre minimal de photographes distincts par groupe (par exemple groupby("cluster")["id_photographer"].nunique()), ou regrouper directement en pondérant chaque photographe une seule fois par lieu.
  • Remplacer le découpage horaire par des sessions : deux photos d’un même utilisateur appartiennent à la même session si elles sont séparées de moins de 30 minutes, par exemple, quel que soit le changement d’heure.
  • Travailler en mètres. Projeter les coordonnées ou utiliser la distance haversine, pour que eps ait la même signification dans toutes les directions.
  • Tenir compte des densités variables. Un seul eps ne peut convenir à la fois à l’hypercentre et aux parcs ; HDBSCAN ou OPTICS seraient plus adaptés.
  • Choisir des tags discriminants. Des tags comme « rennes » ou « bretagne » sont fréquents partout : comme la règle œufs ⇒ haricots du chapitre 9, ils ont un lift proche de 1 et ne décrivent pas un lieu particulier. Mieux vaut classer les tags d’un groupe selon leur fréquence dans le groupe rapportée à leur fréquence globale, et compter chaque tag une fois par photographe.
  • Faire varier les paramètres (eps, min_samples, support minimal) et vérifier que les groupes principaux sont stables, plutôt que de se fier à un seul réglage.
  • Un point d’intérêt est un lieu photographié par de nombreux utilisateurs distincts ; toute la démarche doit servir cette définition.
  • L’exploration a révélé 29 541 lignes pour 4 148 photos distinctes ; la limitation de l’effet album (une photo par photographe et par heure) en a laissé 1 232.
  • DBSCAN (eps = 0,0003 degré, min_samples = 7) a trouvé 20 groupes et du bruit, sans qu’on ait fixé leur nombre.
  • Les tags nettoyés (minuscules, accents, mots vides, identifiants d’appareil, caractères spéciaux) et Apriori (support minimal 0,4, itemsets d’au moins deux tags) ont étiqueté 9 groupes sur 20.
  • Plusieurs groupes proviennent d’un seul auteur : il faut compter des photographes distincts, travailler en mètres, gérer des densités variables et privilégier les tags discriminants.

Exercice · Mesurer l'effet du nettoyage

facile

À partir des chiffres du TP (29 541 lignes, 4 148 photos distinctes, 1 232 photos après la règle d’une photo par heure), calculez : la part des lignes qui étaient des doublons, le nombre moyen de copies par photo, et la part des photos distinctes supprimées par la règle horaire.

Voir la solution
  • Part de doublons : 1−4 148/29 541≈1−0,140=0,8601 - 4\,148 / 29\,541 \approx 1 - 0{,}140 = 0{,}860, soit environ 86 % des lignes.
  • Copies par photo : 29 541/4 148≈7,129\,541 / 4\,148 \approx 7{,}1 lignes en moyenne pour chaque photo.
  • Règle horaire : il reste 1 232/4 148≈29,7 %1\,232 / 4\,148 \approx 29{,}7\,\% des photos distinctes, donc environ 70 % sont supprimées.

Au total, moins d’une ligne sur vingt (1 232/29 541≈4,2 %1\,232 / 29\,541 \approx 4{,}2\,\%) est utilisée pour le regroupement. Sans ces deux étapes, les densités mesurées par DBSCAN auraient reflété les copies et les rafales de photos, pas l’intérêt des lieux.

En code

lignes, photos, apres_horaire = 29_541, 4_148, 1_232
print(round(1 - photos / lignes, 3)) # 0.86 : 86 % de doublons
print(round(lignes / photos, 1)) # 7.1 copies par photo
print(round(apres_horaire / photos, 3)) # 0.297 : il reste 29,7 %, donc 70 % supprimées
print(round(apres_horaire / lignes, 3)) # 0.042 : moins d'une ligne sur vingt

Exercice · Critiquer la règle « une photo par heure »

moyen

Donnez deux situations où la règle « une photo par photographe et par heure » se trompe, l’une où elle garde trop de photos, l’autre où elle en garde trop peu. Proposez une règle plus fidèle à la définition d’un point d’intérêt.

Voir la solution

Trop de photos gardées. Un passionné d’avions passe l’après-midi de 13 h à 18 h au bord des pistes : la règle garde une photo par heure, soit 5 photos au même endroit, d’une seule personne. Répété sur plusieurs journées, cela suffit à créer un groupe dense avec min_samples = 7. De même, deux photos prises à 13 h 59 et à 14 h 01 comptent double, car elles tombent dans deux heures différentes.

Trop peu. Un touriste visite en une heure le parlement de Bretagne puis la place des Lices, à quelques centaines de mètres : la règle ne garde qu’une de ses photos, et l’un des deux lieux perd un visiteur réel.

Règle plus fidèle. Puisqu’un point d’intérêt est défini par des personnes distinctes, le plus simple est de dédoublonner par photographe et par lieu (par exemple, une photo par photographe et par cellule de 50 m, ou par groupe DBSCAN), puis d’exiger un nombre minimal de photographes distincts par groupe. On peut aussi découper les photos d’un même utilisateur en sessions (écart de moins de 30 minutes) plutôt qu’en heures d’horloge.

En code

import pandas as pd
# Un passionné d'avions de 13 h à 18 h à l'aéroport, puis un touriste en ville
heures = ["13:10", "13:59", "14:01", "15:20", "16:05", "17:30", "17:45", "10:05", "10:40"]
photos = pd.DataFrame({
"auteur": ["avion"] * 7 + ["touriste"] * 2,
"lieu": ["aeroport"] * 7 + ["parlement", "lices"],
"prise": pd.to_datetime(["2019-06-01 " + h for h in heures]),
})
# Règle du TP : une photo par auteur et par heure d'horloge
photos["heure"] = photos["prise"].dt.floor("h")
horaire = photos.drop_duplicates(["auteur", "heure"])
print(horaire.groupby("lieu").size().to_dict())
# {'aeroport': 5, 'parlement': 1} : 5 photos d'un seul auteur, et les Lices perdues
# Règle plus fidèle : une photo par auteur et par lieu, puis on compte des personnes
par_lieu = photos.drop_duplicates(["auteur", "lieu"])
print(par_lieu.groupby("lieu")["auteur"].nunique().to_dict())
# {'aeroport': 1, 'lices': 1, 'parlement': 1} : un seul photographe par lieu

Exercice · Filtrer les groupes d'un seul auteur

difficile

Écrivez, avec pandas, le code qui (a) calcule pour chaque groupe DBSCAN son nombre de photos et son nombre de photographes distincts, (b) ne garde comme points d’intérêt que les groupes d’au moins 5 photographes distincts, et (c) pour l’étiquetage, compte chaque tag au plus une fois par photographe dans un groupe. Expliquez pourquoi l’étape (c) change les étiquettes des groupes 2, 3, 6 et 14.

Voir la solution
groupes = photos[photos["cluster"] >= 0]
# (a) taille et nombre de photographes distincts par groupe
bilan = groupes.groupby("cluster").agg(
nb_photos=("id_photo", "size"),
nb_photographes=("id_photographer", "nunique"))
# (b) seuls les groupes d'au moins 5 photographes distincts sont des POI
poi = bilan[bilan["nb_photographes"] >= 5].index
groupes = groupes[groupes["cluster"].isin(poi)]
# (c) une transaction par photographe : l'union de ses tags dans le groupe
par_auteur = (groupes.explode("tags_propres")
.groupby(["cluster", "id_photographer"])["tags_propres"]
.apply(lambda t: sorted(set(t.dropna()))))

On applique ensuite Apriori à chaque groupe, sur les transactions de par_auteur, au lieu des transactions par photo.

Pourquoi les étiquettes changent-elles ? Dans les groupes 2, 3, 6 et 14, les tags d’aviation et le pseudonyme viennent d’un seul utilisateur qui les répète sur chacune de ses photos. Par photo, ils atteignent facilement un support de 0,4. Par photographe, ils ne comptent plus qu’une transaction parmi toutes celles des autres auteurs du groupe : leur support s’effondre, et seuls les tags partagés par plusieurs personnes peuvent encore former une étiquette. Le groupe 2, dont les 43 photos viennent d’un même photographe, n’aurait d’ailleurs qu’un seul photographe distinct et serait éliminé dès l’étape (b).

En code

import pandas as pd
# Mini-jeu : le groupe 2 vient d'un seul auteur, le groupe 10 de 5 auteurs
photos = pd.DataFrame({
"id_photo": range(1, 11),
"cluster": [2, 2, 2, 2, 10, 10, 10, 10, 10, 10],
"id_photographer": ["pilote"] * 4 + ["a", "b", "c", "d", "e", "e"],
"tags_propres": [["spotting", "rns"]] * 4 + [["jacobins", "couvent"], ["jacobins"],
["couvent", "rennes"], ["jacobins", "couvent"], ["rennes"],
["rennes", "nuit"]],
})
groupes = photos[photos["cluster"] >= 0]
# (a) taille et nombre de photographes distincts par groupe
bilan = groupes.groupby("cluster").agg(
nb_photos=("id_photo", "size"),
nb_photographes=("id_photographer", "nunique"))
print(bilan.to_dict("index")) # {2: 4 photos, 1 photographe ; 10: 6 photos, 5 photographes}
# (b) seuls les groupes d'au moins 5 photographes distincts sont des POI
poi = bilan[bilan["nb_photographes"] >= 5].index
groupes = groupes[groupes["cluster"].isin(poi)]
# (c) une transaction par photographe : l'union de ses tags dans le groupe
par_auteur = (groupes.explode("tags_propres")
.groupby(["cluster", "id_photographer"])["tags_propres"]
.apply(lambda t: sorted(set(t.dropna()))))
print(par_auteur.tolist())
# [['couvent', 'jacobins'], ['jacobins'], ['couvent', 'rennes'], ['couvent', 'jacobins'],
# ['nuit', 'rennes']] : 5 transactions, l'auteur e ne compte qu'une fois « rennes »