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 problème et les données
Section intitulée « Le problème et les données »Le fichier flickrRennes.csv contient une ligne par photo, avec :
id_photo, un identifiant censé être unique ;title, le titre, ettags, les mots-clés libres saisis par le photographe ;id_photographer, l’identifiant de l’utilisateur qui a publié la photo ;latetlong, 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.
Explorez et nettoyez
Section intitulée « Explorez et nettoyez »Un premier regard
Section intitulée « Un premier regard »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)) # 29541print(photos[["lat", "long"]].agg(["mean", "median"]))print(photos["date_taken_year"].agg(["min", "max"])) # 2004 et 2019print(photos["id_photographer"].nunique()) # 213print(photos["id_photo"].nunique()) # 4148 : alerte !Les doublons
Section intitulée « Les doublons »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.
L’effet « album »
Section intitulée « L’effet « album » »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 heureprint(len(photos)) # 1232Il 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.
Regroupez les photos avec DBSCAN
Section intitulée « Regroupez les photos avec DBSCAN »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 à 19On 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.
Caractérisez les groupes avec leurs tags
Section intitulée « Caractérisez les groupes avec leurs tags »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 :
- tout mettre en minuscules ;
- supprimer les accents (« marché » devient « marche ») ;
- retirer les mots vides du français (« le », « de », « à »…), avec la liste fournie par nltk ;
- retirer les identifiants d’appareil photo, comme
img_1234oudsc_0042; - 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 refrom 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 apriorifrom 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 obtenue | Lecture |
|---|---|---|
| 10 | jacobins, couvent | le couvent des Jacobins, en centre-ville : un vrai lieu d’intérêt |
| 8 | frac, bretagne, rennes | le FRAC Bretagne (Fonds régional d’art contemporain) : un vrai lieu |
| 12 | rns, spotting, rennes | l’observation d’avions (spotting) ; RNS est le code de l’aéroport de Rennes |
| 2, 3, 6, 14 | une douzaine de tags d’observation d’avions (spotting, aviation, avion, aircraft, aeronautique, plane…) et le pseudonyme d’un photographe | les photos d’un même passionné d’aviation |
| 0 | le nom d’un studio photo et celui de son auteur | la production d’un studio professionnel |
| 11 | instagram, iphoneography, square, app, format | des tags ajoutés automatiquement par une application mobile |
| les 11 autres | aucune | pas d’itemset d’au moins deux tags avec un support de 0,4 |
Analyse critique
Section intitulée « Analyse critique »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.
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
epsait la même signification dans toutes les directions. - Tenir compte des densités variables. Un seul
epsne 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.
En résumé
Section intitulée « En résumé »- 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.
Exercices
Section intitulée « Exercices »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 : , soit environ 86 % des lignes.
- Copies par photo : lignes en moyenne pour chaque photo.
- Règle horaire : il reste des photos distinctes, donc environ 70 % sont supprimées.
Au total, moins d’une ligne sur vingt () 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 doublonsprint(round(lignes / photos, 1)) # 7.1 copies par photoprint(round(apres_horaire / photos, 3)) # 0.297 : il reste 29,7 %, donc 70 % suppriméesprint(round(apres_horaire / lignes, 3)) # 0.042 : moins d'une ligne sur vingtlignes <- 29541; photos <- 4148; apres_horaire <- 1232
round(1 - photos / lignes, 3) # 0.86 : 86 % de doublonsround(lignes / photos, 1) # 7.1 copies par photoround(apres_horaire / photos, 3) # 0.297 : il reste 29,7 %, donc 70 % suppriméesround(apres_horaire / lignes, 3) # 0.042 : moins d'une ligne sur vingtExercice · Critiquer la règle « une photo par heure »
moyenDonnez 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 villeheures = ["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'horlogephotos["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 personnespar_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# Un passionné d'avions de 13 h à 18 h à l'aéroport, puis un touriste en villeheures <- c("13:10", "13:59", "14:01", "15:20", "16:05", "17:30", "17:45", "10:05", "10:40")photos <- data.frame( auteur = rep(c("avion", "touriste"), c(7, 2)), lieu = c(rep("aeroport", 7), "parlement", "lices"), prise = as.POSIXct(paste("2019-06-01", heures), tz = "UTC"))
# Règle du TP : une photo par auteur et par heure d'horlogephotos$heure <- format(photos$prise, "%Y-%m-%d %H")horaire <- photos[!duplicated(photos[c("auteur", "heure")]), ]table(horaire$lieu) # aeroport 5, parlement 1 : un seul auteur, et les Lices perdues
# Règle plus fidèle : une photo par auteur et par lieu, puis on compte des personnespar_lieu <- photos[!duplicated(photos[c("auteur", "lieu")]), ]tapply(par_lieu$auteur, par_lieu$lieu, function(a) length(unique(a))) # 1 partoutExercice · 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 groupebilan = 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 POIpoi = bilan[bilan["nb_photographes"] >= 5].indexgroupes = groupes[groupes["cluster"].isin(poi)]
# (c) une transaction par photographe : l'union de ses tags dans le groupepar_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 auteursphotos = 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 groupebilan = 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 POIpoi = bilan[bilan["nb_photographes"] >= 5].indexgroupes = groupes[groupes["cluster"].isin(poi)]
# (c) une transaction par photographe : l'union de ses tags dans le groupepar_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 »# Mini-jeu : le groupe 2 vient d'un seul auteur, le groupe 10 de 5 auteursphotos <- data.frame(id_photo = 1:10, cluster = rep(c(2, 10), c(4, 6)), id_photographer = c(rep("pilote", 4), "a", "b", "c", "d", "e", "e"))photos$tags_propres <- c(rep(list(c("spotting", "rns")), 4), list(c("jacobins", "couvent"), "jacobins", c("couvent", "rennes"), c("jacobins", "couvent"), "rennes", c("rennes", "nuit")))groupes <- photos[photos$cluster >= 0, ]
# (a) taille et nombre de photographes distincts par groupebilan <- data.frame( nb_photos = tapply(groupes$id_photo, groupes$cluster, length), nb_photographes = tapply(groupes$id_photographer, groupes$cluster, function(a) length(unique(a))))bilan # groupe 2 : 4 photos, 1 photographe ; groupe 10 : 6 photos, 5 photographes
# (b) seuls les groupes d'au moins 5 photographes distincts sont des POIpoi <- rownames(bilan)[bilan$nb_photographes >= 5]groupes <- groupes[groupes$cluster %in% poi, ]
# (c) une transaction par photographe : l'union de ses tags dans le groupecles <- paste(groupes$cluster, groupes$id_photographer)par_auteur <- lapply(split(groupes$tags_propres, cles), function(t) sort(unique(unlist(t))))sapply(par_auteur, paste, collapse = ",")# 10 a "couvent,jacobins" ; 10 b "jacobins" ; 10 c "couvent,rennes" ;# 10 d "couvent,jacobins" ; 10 e "nuit,rennes"