🏁 Projet Final — Niveau Intermédiaire : La bataille du signal — reconquérir le marché télécom nigérian, État par État
Bootcamp Data Analyst — From Zero to Hero | Niveau Intermédiaire · Projet Final
Bienvenue dans le capstone du Niveau Intermédiaire. Ici, on ne fait plus des exercices isolés : on construit un pipeline data complet, de bout en bout, comme en vrai. Tu pars de fichiers bruts trouvés sur le web, tu montes une base propre, tu interroges, tu testes, tu visualises, tu publies, et tu conclus devant un comité. Tout ce que tu as appris depuis le début du niveau va servir, dans le même projet, sur les mêmes données. C'est long, c'est exigeant, et à la fin tu auras une pièce solide à mettre dans ton portfolio.
Prends ton temps. Lis chaque énoncé, essaie avant d'ouvrir la correction. Le but n'est pas d'aller vite, c'est de tenir toute la chaîne.
🎯 Ta mission
Tu viens d'être recruté·e comme Data Analyst chez un opérateur télécom nigérian. À toi de choisir ton employeur parmi les trois challengers du marché : GLO, Airtel ou 9mobile (dans les données NBS, 9mobile apparaît sous son ancien nom réglementaire EMTS). Une seule interdiction : tu ne travailles pas pour MTN. MTN, c'est le leader, le géant qu'on cherche à rattraper. Toi, tu joues l'outsider.
Le contexte : le comité de direction prépare le plan d'acquisition d'abonnés des deux prochaines années. Le budget marketing et réseau est limité, il faut choisir où le déployer. Et le comité te pose une seule question, celle qui commande tout le projet :
« Où concentrer nos efforts ? Quels États et quelles zones sont sous-exploités ? Où sommes-nous faibles face à MTN ? Où le potentiel de croissance est-il le plus fort ? »
Ton job pendant tout ce projet, c'est de transformer des tableurs bruts du régulateur en une réponse claire et défendable à cette question. Pas un joli graphique de plus : une recommandation d'allocation que le comité pourra suivre.
Au bout du pipeline, tu auras produit :
- 🗄️ Une base PostgreSQL propre, en schéma étoile (table de faits
abonnements+ dimensionetats), alimentée par un nettoyage reproductible des sources brutes ; - 🔎 Des analyses SQL avancées : taux de pénétration par État, parts de marché, classements, fenêtres glissantes sur les trimestres, écarts face à MTN ;
- 📈 Des tests statistiques pour ne pas confondre un vrai signal avec du bruit (une zone est-elle significativement sous-pénétrée ? une différence de parts est-elle réelle ?) ;
- 📊 Un dashboard Power BI lisible par un décideur non-technique ;
- 🚀 Une application Streamlit publiée en ligne, interactive, que le comité peut ouvrir dans son navigateur ;
- 🎤 Une présentation façon Pyramid Principle : la réponse d'abord, les preuves ensuite — pour convaincre en réunion, pas pour noyer.
Le tout versionné sur Git du premier au dernier commit, comme un vrai projet d'équipe.
🛠️ Ce que ce projet mobilise
Ce capstone est conçu pour rejouer tout le Niveau Intermédiaire dans un seul fil. Chaque étape rebranche un module :
| Étape du pipeline | Module rebranché |
|---|---|
Repo Git, commits propres, branches, .gitignore pour ne pas versionner les données lourdes |
M13 — Git & versioning |
Récupération, dépivotage (melt) et nettoyage des tableurs NBS, gestion des valeurs manquantes et des lignes parasites |
M14 / M15 — Nettoyage & manipulation de données |
| Modélisation en schéma étoile, jointures, fenêtres, CTE, agrégations dans PostgreSQL | M16 — SQL avancé |
| Dashboard décideur, mesures, mise en page lisible | M17 — Power BI |
| Tests d'hypothèses, seuil de signification, lecture correcte d'une p-value | M18 — Statistiques |
| Appel de l'API CKAN de HDX pour la population + app interactive déployée | M19 — API & Streamlit |
| Structuration du message, Pyramid Principle, recommandation actionnable | M20 — Data storytelling |
Si un module est encore flou pour toi, c'est le moment idéal d'y retourner : ici, tu le pratiques sur un cas réel, pas sur un exemple jouet.
📦 Les données
Comme dans le vrai métier — et exactement comme au projet final du Niveau Débutant — c'est toi qui vas chercher tes données. Personne ne te sert un CSV propre sur un plateau. Tu télécharges les sources, tu comprends leur structure bancale, et tu les redresses. Ça fait partie du travail, et c'est souvent 80 % du travail.
Le terrain de jeu, c'est le Nigéria : le plus grand marché télécom d'Afrique de l'Ouest, un vrai marché ouvert avec plusieurs opérateurs qui se battent, et un régulateur qui publie ses chiffres — le NBS (National Bureau of Statistics). Des données réelles, sur un marché réel.
Tu vas croiser trois sources :
1. Les abonnements télécom — NBS (à télécharger)
Le classeur trimestriel du NBS, qui recense le nombre d'abonnés par État et par opérateur (voix, internet, portabilité), du 2e trimestre 2021 au 4e trimestre 2023.
- URL :
https://nigerianstat.gov.ng/resource/Telecoms_Q4_2023.xlsx
⚠️ Prépare-toi : ce fichier est volontairement pénible. Une trentaine d'onglets, un onglet par trimestre et par type de service, avec des noms incohérents (les onglets 2021 s'appellent « Q2 Voice 2021 », ceux de 2022-2023 « Q1 2022 Voice » — l'ordre des mots change), des en-têtes éclatés sur plusieurs lignes, des lignes de titre parasites, une ligne « OTHERS (UNDEFINED) » et une ligne « TOTAL » à écarter, et une flopée de colonnes d'opérateurs quasi-vides (IPNX, SMILE, NTEL…) à ignorer. Le format est large (États en lignes, opérateurs en colonnes) : il faudra le dépivoter en format long pour le charger proprement en base. C'est le cœur du travail de nettoyage.
2. La population par État — HDX (via API)
Pour transformer un nombre d'abonnés brut en taux de pénétration, il te faut la population de chaque État. Elle vient de HDX (Humanitarian Data Exchange), jeu de données « Nigeria - Subnational Population Statistics ».
- Page du jeu de données :
https://data.humdata.org/dataset/cod-ps-nga - Point d'entrée API (CKAN) pour lister les ressources :
https://data.humdata.org/api/3/action/package_show?id=cod-ps-nga
⚠️ HDX renvoie une erreur 403 si tu l'appelles sans en-tête User-Agent de navigateur. En Python requests, ajoute un header du type User-Agent: Mozilla/5.0 .... C'est ton maillon API du projet : tu passes par l'API pour trouver l'URL du CSV, puis tu le récupères. Colonnes utiles : ADM1_EN (nom d'État), ADM1_PCODE (code NG001…NG037), T_TL (population totale).
3. Les zones géopolitiques — table de référence (à fournir toi-même)
Le comité raisonne rarement État par État : il raisonne par zone géopolitique (les 6 grandes régions du Nigéria). Il n'existe pas de fichier tout prêt : tu vas construire toi-même la petite table de correspondance des 37 États vers leur zone. La voici, à intégrer telle quelle :
| Zone géopolitique | États |
|---|---|
| North Central | Niger, Kogi, Benue, Plateau, Nasarawa, Kwara, Federal Capital Territory |
| North East | Bauchi, Borno, Taraba, Adamawa, Gombe, Yobe |
| North West | Zamfara, Sokoto, Kaduna, Kebbi, Katsina, Kano, Jigawa |
| South East | Enugu, Imo, Ebonyi, Abia, Anambra |
| South South | Bayelsa, Akwa Ibom, Edo, Rivers, Cross River, Delta |
| South West | Oyo, Ekiti, Osun, Ondo, Lagos, Ogun |
Le point qui pique à la jointure : le NBS écrit FCT là où HDX écrit FEDERAL CAPITAL TERRITORY. C'est le seul recodage indispensable pour aligner les deux sources (36 États sur 37 matchent déjà à l'identique une fois les noms passés en MAJUSCULES). Garde-le en tête, on y reviendra à l'étape de nettoyage.
📝 Une note méthodo honnête
Un·e bon·ne analyste assume les limites de ses données. En voici une, à documenter dès le départ :
Les abonnements couvrent 2021 à 2023, mais la population de référence HDX est celle de 2022. On utilise donc une population fixe (2022) comme proxy pour tous les trimestres.
C'est acceptable ici : sur 2-3 ans la population d'un État bouge peu par rapport aux écarts de pénétration qu'on veut mettre en évidence, et surtout tous les États sont traités avec la même règle, donc les comparaisons entre États restent valides. Mais on l'écrit noir sur blanc : un taux de pénétration légèrement > 100 % sur un État très couvert n'est pas un bug, c'est le signe de cartes SIM multiples et d'un dénominateur figé. Le mentionner dans ta conclusion, c'est un réflexe de sérieux — pas un aveu de faiblesse.
➡️ La suite. Maintenant que la mission et les sources sont posées, on attaque le pipeline dans l'ordre : initialiser le repo Git, puis récupérer et nettoyer les trois sources, avant de les charger dans PostgreSQL. On y va étape par étape.
Étape 1 — Récupérer les données (téléchargement + API)
Avant d'analyser quoi que ce soit, il faut poser les deux sources sur la table : les abonnements télécom (qui dit combien d'abonnés MTN, GLO, Airtel et 9mobile ont dans chaque État) et la population par État (qui te dira, à l'étape jointure, ton taux de pénétration). Ces deux briques ne se récupèrent pas de la même façon : le télécom est un fichier Excel qu'on télécharge et qu'on ouvre, la population passe par une API — exactement le réflexe travaillé au Module 19. Ici on ne fait que récupérer et regarder la structure brute : le nettoyage, c'est l'étape 2.
A) Télécharger et charger le classeur télécom du NBS
Le régulateur statistique nigérian (NBS) publie chaque trimestre un classeur Excel avec les abonnements par opérateur et par État. On va le récupérer, puis l'ouvrir avec pandas sans nettoyer : on veut juste voir à quoi ressemble la bête (combien d'onglets, comment un onglet est structuré) pour préparer le nettoyage de l'étape suivante.
Ta tâche :
- Télécharge le classeur depuis l'URL du NBS (
bashaveccurl/wget, ou directement en Python). - Ouvre-le avec pandas et liste tous les noms d'onglets — tu dois repérer combien il y en a et remarquer les incohérences de nommage.
- Charge un seul onglet Voice en brut (sans header,
header=None) et regarde les premières lignes pour comprendre où sont les vrais en-têtes et où commencent les données.
👉 Correction
Téléchargement (bash) :
bash# Le NBS publie le classeur Q4 2023 en accès libre
curl -L -o telecoms_nbs.xlsx \
"https://nigerianstat.gov.ng/resource/Telecoms_Q4_2023.xlsx"Chargement et inspection de la structure (Python) :
pythonimport pandas as pd
CHEMIN = "telecoms_nbs.xlsx"
# 1) Lister les onglets sans tout charger en mémoire : ExcelFile ouvre le classeur
# et expose juste la liste des feuilles.
xls = pd.ExcelFile(CHEMIN)
print(f"Nombre d'onglets : {len(xls.sheet_names)}")
for nom in xls.sheet_names:
print(repr(nom)) # repr() pour VOIR les espaces cachés en début de nom⚠️ Deux pièges de nommage à repérer dès maintenant (ils te mordront à l'étape 2) :
- Il existe un onglet " Operators" avec un espace au début — d'où le
repr(), qui te rend l'espace visible (' Operators') au lieu de te le cacher.- Le nommage des onglets trimestriels n'est pas homogène entre les années : les feuilles de 2021 suivent un ordre (
"Q2 Voice 2021"), celles de 2022-2023 un autre ("Q1 2022 Voice"). Tu ne pourras donc pas construire le nom d'un onglet avec une seule règle de formatage ; garde ça en tête pour la boucle de chargement de l'étape 2.
python# 2) Charger UN onglet Voice en brut, sans interpréter d'en-tête.
# header=None => pandas ne devine rien, tu vois les lignes telles quelles.
brut = pd.read_excel(CHEMIN, sheet_name="Q4 2023 Voice", header=None)
print("Dimensions (lignes, colonnes) :", brut.shape)
# Regarder le haut du fichier pour localiser en-têtes et début des données
print(brut.head(9))Comment lire ce que tu obtiens (sans nettoyer encore) :
- Les 4 premières lignes sont des titres parasites (ligne 3 = un libellé du genre "ACTIVE VOICE SUBSCRIPTIONS…"), pas des données.
- Le vrai en-tête est éclaté sur plusieurs lignes (lignes 5 à 7) ; c'est la ligne 7 qui porte les noms d'opérateurs (MTN, GLO, AIRTEL, EMTS…).
- Les données commencent à la ligne 8 : colonne A = numéro d'ordre, colonne B = l'État en MAJUSCULES, colonnes suivantes = le nombre d'abonnés par opérateur.
- Le format est large (un État par ligne, les opérateurs en colonnes) et il y a des colonnes quasi vides + une ligne parasite
OTHERS (UNDEFINED).
Ne corrige rien ici : le but de l'étape 1 est juste de confirmer que le fichier se télécharge, s'ouvre, et de cartographier sa structure. Tout le dépivotage/nettoyage (passer en format long état / opérateur / type / trimestre / nb_abonnes, écarter les colonnes vides et la ligne OTHERS) arrive à l'étape 2.
B) Récupérer la population par État via l'API CKAN de HDX
La population, elle, ne se télécharge pas d'un clic : elle vit sur HDX (Humanitarian Data Exchange), une plateforme qui expose ses jeux de données via une API CKAN. C'est ici que tu réutilises tout le M19 : appeler une API, parser le JSON de réponse pour retrouver l'URL du fichier, puis télécharger ce fichier. On ne code pas l'URL du CSV en dur — on la découvre dans la réponse de l'API, comme un vrai pipeline qui doit survivre au jour où HDX change son lien.
Ta tâche :
- Appelle l'endpoint CKAN
package_showde HDX pour le paquetcod-ps-nga(population du Nigéria). - Parse le JSON : d'abord vérifie que la réponse est bien un succès, puis parcours la liste des ressources (
result.resources) pour retrouver le CSV admin-1 (celui dont le nom contientadm1). - Récupère son URL de téléchargement, télécharge le CSV et charge-le dans pandas. Vérifie que les colonnes attendues (
ADM1_EN,ADM1_PCODE,T_TL) sont là. - Deux réflexes obligatoires du M19 : (a) HDX renvoie 403 si tu n'envoies pas un User-Agent de navigateur → mets-en un ; (b) ne te fie pas au seul code HTTP 200 → vérifie le contenu de la réponse (est-ce bien du JSON/CSV, et pas une page HTML d'erreur ?).
👉 Correction
pythonimport io
import requests
import pandas as pd
# --- Réflexe M19 n°1 : HDX refuse (403) les requêtes "sans navigateur".
# On envoie un User-Agent de navigateur sur TOUS les appels vers HDX.
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
)
}
API_URL = "https://data.humdata.org/api/3/action/package_show?id=cod-ps-nga"
# 1) Appel de l'API CKAN
reponse = requests.get(API_URL, headers=HEADERS, timeout=60)
# --- Réflexe M19 n°2 : ne PAS se contenter du statut HTTP.
# On lève une erreur explicite si le code n'est pas 2xx...
reponse.raise_for_status()
# ...PUIS on vérifie que le contenu est cohérent (CKAN renvoie toujours
# un JSON avec une clé booléenne "success"). Si "success" est False,
# l'API a répondu 200 mais n'a rien trouvé : on s'arrête net.
data = reponse.json()
if not data.get("success"):
raise RuntimeError("L'API CKAN a répondu, mais success=False (paquet introuvable ?)")
# 2) Parser le JSON : la liste des fichiers est sous result -> resources
ressources = data["result"]["resources"]
# On cherche LE csv admin-1 (un État = un niveau admin-1).
# On filtre sur le format ET sur "adm1" dans le nom, sans coder l'URL en dur.
csv_adm1 = next(
res for res in ressources
if res.get("format", "").upper() == "CSV"
and "adm1" in res.get("name", "").lower()
)
url_csv = csv_adm1["download_url"] # l'URL est DÉCOUVERTE, pas devinée
print("Fichier repéré :", csv_adm1["name"])
# 3) Télécharger le CSV (toujours avec le User-Agent navigateur)
tele = requests.get(url_csv, headers=HEADERS, timeout=60)
tele.raise_for_status()
# Re-réflexe M19 n°2 : un serveur peut renvoyer 200 + une page HTML d'erreur.
# Avant de parser en CSV, on s'assure que ça ne commence pas par du HTML.
debut = tele.text.lstrip().lower()
if debut.startswith("<!doctype") or debut.startswith("<html"):
raise RuntimeError("Réponse 200 mais contenu HTML : ce n'est pas le CSV attendu.")
# io.StringIO permet de lire le texte téléchargé comme s'il s'agissait d'un fichier
pop = pd.read_csv(io.StringIO(tele.text))
# 4) Vérifier que les colonnes dont on aura besoin sont bien présentes
colonnes_cles = ["ADM1_EN", "ADM1_PCODE", "T_TL"]
manquantes = [c for c in colonnes_cles if c not in pop.columns]
if manquantes:
raise KeyError(f"Colonnes attendues absentes : {manquantes}")
print("Colonnes utiles présentes ✔ — Dimensions :", pop.shape)
print(pop[colonnes_cles].head())Comment lire ce que tu obtiens :
ADM1_EN= le nom de l'État (celui qui servira de clé de jointure avec le télécom à l'étape 3),ADM1_PCODE= son code officiel (NG001…NG037),T_TL= la population totale.- Tu dois retrouver une ligne par État — c'est le signe que tu as bien attrapé le fichier admin-1 (et pas un niveau plus fin ou le total national).
- Si l'un des deux
raisese déclenche : soit ton User-Agent manque (403 → réflexe n°1), soit tu as reçu une page d'erreur déguisée en 200 (réflexe n°2). Dans les deux cas, le problème n'est pas ton parsing mais la récupération — c'est précisément le genre de panne que le M19 t'a appris à diagnostiquer avant de blâmer ton code.
🔗 Lien avec le M19 : cet appel
package_show+ parcours deresult.resourcesest un cas d'école du module « APIs & JSON ». On n'a pas codé le lien du CSV en dur : on interroge l'API, on navigue dans le JSON pour retrouver la ressource par son nom, et on ne fait confiance ni au statut HTTP seul ni au format supposé — on vérifie le contenu. C'est exactement le pipeline robuste que tu voudras en production.
À la fin de cette étape, tu as deux objets en mémoire : le classeur télécom brut (34 onglets, format large, en-têtes à démêler) et le tableau de population par État (propre, une ligne par État). Aucun des deux n'est encore exploitable tel quel pour répondre au comité de direction — mais les deux sources sont sur la table. Direction l'étape 2 : nettoyer et dépivoter le télécom.
Étape 2 — Nettoyer et restructurer
Tu as maintenant les fichiers bruts sous la main. Sauf que… le classeur du NBS n'a rien d'une base propre : des titres qui traînent, un en-tête éclaté sur plusieurs lignes, des noms d'États écrits de trois façons différentes selon le trimestre, des lignes « TOTAL » planquées au milieu, et même un trimestre entier qui ne suit pas le même format que les autres. C'est exactement le quotidien d'un Data Analyst : 90 % du boulot d'analyse, c'est du nettoyage. On y va méthodiquement.
L'objectif de l'étape est clair : partir de ce classeur en désordre et en sortir une seule table longue et propre, au format (etat, operateur, type_service, trimestre, nb_abonnes), prête à charger dans PostgreSQL à l'étape suivante.
⚠️ Avant de coder : ouvre le classeur dans Excel/LibreOffice et regarde un onglet « Voice ». On ne nettoie jamais à l'aveugle. Tu verras tout de suite les titres parasites et l'en-tête sur plusieurs lignes. Un DA qui n'a pas ouvert le fichier avant de le parser fonce dans le mur.
2.A — Charger un onglet en reconstruisant l'en-tête
Prends un onglet Voice ou Internet (par exemple Q4 2023 Voice). Si tu fais un pd.read_excel() naïf, pandas prend la première ligne comme en-tête… et tu récupères une colonne qui s'appelle « ACTIVE VOICE SUBSCRIPTIONS PER STATE… » avec des Unnamed: 1, Unnamed: 2 partout. Inutilisable.
Le problème : dans ces onglets, les vraies colonnes (les opérateurs MTN | GLO | AIRTEL | EMTS | …) ne sont pas sur la première ligne. Il y a 4 lignes de titres parasites au-dessus, l'en-tête est éclaté sur plusieurs lignes (« Mobile (GSM) » au-dessus, les noms d'opérateurs en dessous), et les données ne commencent que plus bas, à la première ligne d'État (ABIA).
Ta tâche : charge un onglet Voice sans te laisser piéger par les titres. Ne te contente PAS de coder un skiprows=6 en dur — ouvre deux ou trois onglets et compare où commence réellement l'en-tête. Tu vas avoir une mauvaise surprise (c'est fait exprès : le NBS n'est pas cohérent d'un trimestre à l'autre). Écris un chargeur qui repère tout seul la ligne des opérateurs et la colonne des États, quel que soit le décalage.
👉 Correction
Première réaction, la plus naturelle : lire brut, sans en-tête, et regarder ce qu'il y a dedans.
pythonimport pandas as pd
FICHIER = "telecoms_nbs.xlsx"
# On lit SANS en-tête pour voir la structure réelle
brut = pd.read_excel(FICHIER, sheet_name="Q4 2023 Voice", header=None)
print(brut.head(10)) # observe : lignes de titre, puis 'MTN','GLO'... puis 'ABIA'...Tu vois alors le schéma : quelques lignes de titre, une ligne avec MTN, GLO, AIRTEL, EMTS, puis les États à partir de ABIA. Tentation : coder skiprows=6, header=[...]. Ne fais pas ça. Si tu inspectes plusieurs onglets, tu découvres que la ligne de l'en-tête change de position d'un trimestre à l'autre (parfois ligne 3, parfois 5, parfois 7), et que sur les onglets Internet de 2023 il y a même une colonne vide en plus au début qui décale tout d'un cran. Un skiprows en dur casserait sur la moitié des onglets.
La bonne approche d'un DA : ne pas coder de position en dur, mais faire repérer les repères par le code. On cherche la ligne où apparaissent MTN ET GLO (c'est la ligne des opérateurs), et la colonne où apparaissent ABIA … ZAMFARA (c'est la colonne des États).
pythonOPERATEURS = ["MTN", "GLO", "AIRTEL", "EMTS"]
def charger_onglet(fichier, onglet):
"""Charge un onglet Voice/Internet en retrouvant seul l'en-tête et la colonne États.
Renvoie un DataFrame large [etat, MTN, GLO, AIRTEL, EMTS], ou None si l'onglet
ne contient pas la matrice État × opérateur."""
brut = pd.read_excel(fichier, sheet_name=onglet, header=None)
# 1) Ligne des opérateurs = la première où l'on trouve à la fois MTN et GLO
ligne_op = None
for r in range(min(15, len(brut))):
cellules = [str(v).strip().upper() for v in brut.iloc[r].tolist()]
if "MTN" in cellules and "GLO" in cellules:
ligne_op = r
break
if ligne_op is None:
return None # cet onglet n'a pas de découpage par opérateur (on y revient en 2.D)
entetes = [str(v).strip().upper() for v in brut.iloc[ligne_op].tolist()]
col_op = {op: entetes.index(op) for op in OPERATEURS if op in entetes}
if len(col_op) < 4:
return None # les 4 opérateurs GSM ne sont pas tous là
# 2) Colonne des États = celle qui contient ABIA … ZAMFARA
col_etat = None
for c in range(min(6, brut.shape[1])):
valeurs = [str(v).strip().upper() for v in brut.iloc[:, c].tolist()]
if "ABIA" in valeurs and "ZAMFARA" in valeurs:
col_etat = c
break
# 3) Première ligne de données = celle où la colonne États vaut ABIA
col_valeurs = [str(v).strip().upper() for v in brut.iloc[:, col_etat].tolist()]
debut = col_valeurs.index("ABIA")
df = brut.iloc[debut:, [col_etat] + [col_op[op] for op in OPERATEURS]].copy()
df.columns = ["etat"] + OPERATEURS
return df
voice_q4_2023 = charger_onglet(FICHIER, "Q4 2023 Voice")
print(voice_q4_2023.head())Pourquoi c'est mieux qu'un skiprows en dur : ce chargeur s'adapte tout seul aux décalages du NBS. La même fonction va marcher sur les 20 onglets Voice/Internet sans que tu retouches un seul numéro de ligne. C'est le réflexe à prendre : quand une source est incohérente, on écrit du code qui cherche les ancres au lieu de supposer des positions fixes.
2.B — Dépivoter (passer du format large au format long)
Le DataFrame que tu viens de charger est en format large : une ligne par État, et une colonne par opérateur (MTN, GLO, AIRTEL, EMTS). Pratique à lire pour un humain, mais inutilisable pour une base de données : tu ne peux pas filtrer « tous les abonnés de tel opérateur » proprement, ni empiler plusieurs trimestres.
La règle en modélisation de données : une observation = une ligne. Ici, une observation, c'est « le nombre d'abonnés d'UN opérateur dans UN État ». On veut donc passer de 4 colonnes-opérateurs à une seule colonne operateur + une colonne nb_abonnes. Cette opération s'appelle dépivoter (unpivot / melt / pivot longer).
Ta tâche : transforme le DataFrame large en format long avec les colonnes etat, operateur, nb_abonnes (une ligne par couple État × opérateur).
👉 Correction
En pandas, l'outil du dépivotage, c'est .melt(). On garde etat comme colonne d'identification (id_vars), et on « fond » les 4 colonnes-opérateurs en deux nouvelles colonnes : une pour le nom (operateur), une pour la valeur (nb_abonnes).
pythonlong = voice_q4_2023.melt(
id_vars="etat", # ce qui reste tel quel, ligne par ligne
value_vars=OPERATEURS, # les colonnes à dépivoter
var_name="operateur", # nom de la nouvelle colonne "clé"
value_name="nb_abonnes", # nom de la nouvelle colonne "valeur"
)
print(long.head())
print(long.shape) # environ 4 fois plus de lignes que le format largeRetiens le principe : melt prend N colonnes et les empile en 2 colonnes (nom, valeur). C'est l'inverse d'un pivot_table. Chaque État apparaît maintenant 4 fois (une par opérateur), et tu as bien « une observation par ligne ». C'est ce format-là, et pas l'autre, qui ira dans PostgreSQL.
2.C — Nettoyer : opérateurs, États, lignes parasites
Ta table longue est structurellement bonne, mais sale. Trois familles de saletés à traiter, et tu ne les verras qu'en inspectant tes valeurs uniques (.unique()) — réflexe DA de base avant tout nettoyage.
- Des faux opérateurs. Le NBS liste douze colonnes d'« opérateurs », mais seules quatre correspondent à de vrais réseaux GSM avec des abonnés :
MTN,GLO,AIRTEL,EMTS. Les autres (IPNX,MTN FIXED,SMILE,NTEL…) sont quasi tout le temps à zéro. Ici, comme ton chargeur n'a gardé que les 4 bonnes colonnes, c'est déjà réglé — mais garde le réflexe. Au passage :EMTS, c'est le nom technique de 9mobile. Personne au comité de direction ne connaît « EMTS ». On renomme. - Des noms d'États incohérents. Regarde de près : selon le trimestre, le même État est écrit
NASARAWAouNASSARAWA(deux S),AKWA IBOMouAKWA I BOM(espace en trop), et il y a même des coquilles de saisie/OCR (1IGAWApour JIGAWA,PIATEAUpour PLATEAU,RIVERS >), voire des retours à la ligne collés dans le nom (CROSS\nRIVER). Si tu ne standardises pas, ta future jointure avec la population va rater ces États. - Des lignes qui ne sont pas des États. Il y a une ligne
OTHERS (Undefined), une ligneTOTAL, parfois des lignes vides ou unISPqui traîne. À jeter.
Et un dernier point, en prévision de la jointure de l'étape suivante : le télécom écrit FCT, alors que le fichier population (HDX) écrit FEDERAL CAPITAL TERRITORY. On recode FCT dès maintenant pour préparer le terrain.
Ta tâche : écris une fonction de nettoyage qui (a) standardise les noms d'États, (b) ne garde que les 37 vrais États, (c) recode FCT, (d) renomme EMTS → 9mobile, (e) s'assure que nb_abonnes est bien numérique.
👉 Correction
Le piège du débutant, c'est de blacklister les saletés une par une (« si c'est TOTAL, jette ; si c'est OTHERS, jette… »). Fragile : il y aura toujours une saleté que tu n'auras pas prévue. La bonne méthode, c'est l'inverse : on se donne le référentiel des 37 États attendus, on corrige les variantes connues, et on ne garde que ce qui est dans le référentiel. Tout ce qui n'y est pas (TOTAL, OTHERS, ISP, lignes vides…) tombe automatiquement.
pythonimport re
# Référentiel : les 37 États du Nigéria en forme canonique (MAJUSCULES),
# avec FCT déjà écrit comme dans le fichier population, pour préparer la jointure.
ETATS_37 = {
"ABIA","ADAMAWA","AKWA IBOM","ANAMBRA","BAUCHI","BAYELSA","BENUE","BORNO",
"CROSS RIVER","DELTA","EBONYI","EDO","EKITI","ENUGU","FEDERAL CAPITAL TERRITORY",
"GOMBE","IMO","JIGAWA","KADUNA","KANO","KATSINA","KEBBI","KOGI","KWARA","LAGOS",
"NASARAWA","NIGER","OGUN","ONDO","OSUN","OYO","PLATEAU","RIVERS","SOKOTO",
"TARABA","YOBE","ZAMFARA",
}
# Table des variantes/coquilles observées -> forme canonique
CORRECTIONS = {
"AKWA I BOM": "AKWA IBOM",
"NASSARAWA": "NASARAWA", # deux S -> un seul S
"1IGAWA": "JIGAWA", # coquille OCR
"PIATEAU": "PLATEAU", # coquille OCR
"RIVERS >": "RIVERS", # caractère parasite
"FCT": "FEDERAL CAPITAL TERRITORY", # recodage pour la jointure population
}
def canon_etat(valeur):
"""Passe un nom d'État en forme canonique : majuscules, espaces normalisés,
retours à la ligne supprimés, variantes/coquilles corrigées."""
s = str(valeur).replace("\n", " ")
s = re.sub(r"\s+", " ", s).strip().upper() # 'CROSS\nRIVER' -> 'CROSS RIVER'
return CORRECTIONS.get(s, s)
def nettoyer(df_long):
df = df_long.copy()
# (a) standardiser les noms d'États
df["etat"] = df["etat"].map(canon_etat)
# (b) ne garder que les 37 vrais États -> jette TOTAL, OTHERS, ISP, vides...
df = df[df["etat"].isin(ETATS_37)]
# (e) forcer le type numérique ; ce qui n'est pas convertible devient NaN puis tombe
df["nb_abonnes"] = pd.to_numeric(df["nb_abonnes"], errors="coerce")
df = df.dropna(subset=["nb_abonnes"])
# (d) EMTS -> 9mobile (nom parlant pour le comité de direction)
df["operateur"] = df["operateur"].replace({"EMTS": "9mobile"})
return df
long_propre = nettoyer(long)
# Contrôle qualité : on DOIT retomber sur exactement 37 États et 4 opérateurs
print("États distincts :", long_propre["etat"].nunique()) # doit valoir 37
print("Opérateurs :", sorted(long_propre["operateur"].unique()))
print("Valeurs manquantes :", long_propre["nb_abonnes"].isna().sum())La leçon : filtrer par un référentiel (garder ce qui est valide) plutôt que par une liste noire (jeter ce qui est invalide). Le premier est robuste — une nouvelle saleté inconnue est éliminée d'office ; le second te lâche au premier cas imprévu. Et toujours finir par un contrôle (.nunique()) : si tu n'obtiens pas 37, c'est qu'une variante d'orthographe t'a échappé et n'est pas dans ta table CORRECTIONS.
2.D — Boucler sur les 11 trimestres et empiler le tout
Jusqu'ici tu as traité un onglet. Le classeur en contient beaucoup plus : 3 onglets par trimestre (Voice, Internet, Porting) sur 11 trimestres, plus un onglet Operators. Pour répondre à la question du comité, il te faut tout l'historique Voice + Internet empilé dans une seule table, avec deux colonnes en plus : type_service et trimestre.
Deux pièges t'attendent :
- Le nommage des onglets est incohérent. Pour 2021, c'est
Q2 Voice 2021(année à la fin) ; pour 2022-2023, c'estQ1 2022 Voice(année au milieu). Impossible de découper le nom « par position ». Il faut extraire le trimestre, l'année et le type quel que soit l'ordre. - Tous les onglets ne se ressemblent pas. L'onglet
Operators(avec un espace au début !) n'a rien à voir, les ongletsPortingne nous intéressent pas, et — surprise que tu ne verras qu'en codant — un trimestre entier ne contient pas de découpage État × opérateur dans son tableau principal. Ton chargeur de 2.A doit savoir dire « cet onglet, je ne sais pas le traiter » (il renvoieNone) au lieu de planter.
Ta tâche : écris une boucle qui parcourt tous les onglets Voice et Internet, extrait type_service + trimestre depuis le nom de l'onglet (en gérant les deux conventions de nommage), applique ton chargeur + dépivot + nettoyage, ignore proprement les onglets non exploitables, et empile tout en une seule table longue (etat, operateur, type_service, trimestre, nb_abonnes).
👉 Correction
Pour parser le nom de l'onglet sans dépendre de l'ordre des mots, on utilise des expressions régulières : on cherche le motif Q1…Q4 où qu'il soit, l'année 20xx où qu'elle soit, et le mot Voice/Internet. Ça marche pour Q2 Voice 2021 comme pour Q1 2022 Voice.
pythonimport re
def parser_onglet(nom):
"""Extrait (type_service, trimestre) du nom d'onglet, quelle que soit la convention.
'Q2 Voice 2021' -> ('Voice', '2021 Q2')
'Q1 2022 Internet'-> ('Internet', '2022 Q1')"""
n = nom.strip()
type_service = "Voice" if "voice" in n.lower() else "Internet"
trimestre_q = re.search(r"Q[1-4]", n).group(0) # Q1..Q4, où qu'il soit
annee = re.search(r"20\d{2}", n).group(0) # 20xx, où qu'elle soit
return type_service, f"{annee} {trimestre_q}" # '2021 Q2' : triable chronologiquementOn note au passage : en écrivant le trimestre 2021 Q2 (année d'abord), un simple tri alphabétique donne l'ordre chronologique. Petit détail, gros confort pour la suite.
Ensuite, la boucle. On liste les onglets, on écarte Porting et Operators, et pour chaque onglet Voice/Internet on enchaîne chargement → dépivot → nettoyage. Si le chargeur renvoie None (onglet non exploitable), on le note et on passe.
pythonxl = pd.ExcelFile(FICHIER)
# On ne garde que Voice/Internet ; on jette Porting et l'onglet ' Operators' (espace initial !)
onglets = [
s for s in xl.sheet_names
if "Porting" not in s and s.strip() != "Operators"
]
morceaux = []
onglets_ignores = []
for onglet in onglets:
df_large = charger_onglet(FICHIER, onglet) # fonction de 2.A
if df_large is None:
onglets_ignores.append(onglet) # pas de matrice État × opérateur -> on saute
continue
type_service, trimestre = parser_onglet(onglet)
long = df_large.melt( # dépivot (2.B)
id_vars="etat", value_vars=OPERATEURS,
var_name="operateur", value_name="nb_abonnes",
)
long = nettoyer(long) # nettoyage (2.C)
long["type_service"] = type_service
long["trimestre"] = trimestre
morceaux.append(long)
# On empile tous les trimestres/types en UNE table longue
abonnements = pd.concat(morceaux, ignore_index=True)
abonnements = abonnements[["etat", "operateur", "type_service", "trimestre", "nb_abonnes"]]
print("Table finale :", abonnements.shape)
print("Onglets ignorés :", onglets_ignores) # regarde ce qui a été écarté, et pose-toi la question !Le point à ne pas manquer : regarde la liste onglets_ignores. Tu vas constater qu'un trimestre entier a été écarté — non pas à cause d'un bug, mais parce que, pour ce trimestre-là, le NBS n'a pas publié le détail par État et par opérateur (le tableau principal ne contient que des totaux par État). Ton code a eu raison de le sauter automatiquement plutôt que de fabriquer des chiffres faux. C'est une décision d'analyse, pas juste de la technique : tu dois en être conscient et le mentionner dans tes livrables (« la période X est absente du découpage par opérateur, source NBS »). Un DA qui masque un trou dans les données au lieu de le documenter, c'est un DA en qui on ne peut pas avoir confiance.
Dernier réflexe, le contrôle de cohérence avant de passer à la suite :
python# Chaque (trimestre, type, opérateur) doit couvrir exactement 37 États
controle = abonnements.groupby(["trimestre", "type_service", "operateur"])["etat"].nunique()
print("États par groupe -> min :", controle.min(), "| max :", controle.max()) # doit être 37 / 37
# Aucune ligne dupliquée sur la clé métier
cle = ["etat", "operateur", "type_service", "trimestre"]
print("Doublons sur la clé :", abonnements.duplicated(cle).sum()) # doit être 0Si min et max valent tous les deux 37 et qu'il n'y a aucun doublon, ta table est saine.
2.E — L'équivalent en R (parité Python / R)
Le même travail se fait en R avec le trio readxl (lecture Excel) + tidyr (dépivotage) + dplyr (nettoyage). Regarde comme la logique est identique — seule la syntaxe change. C'est le cœur du tidyverse : pivot_longer() est l'exact équivalent de melt().
👉 Correction
rlibrary(readxl)
library(tidyr)
library(dplyr)
library(stringr)
fichier <- "telecoms_nbs.xlsx"
operateurs <- c("MTN", "GLO", "AIRTEL", "EMTS")
etats_37 <- c(
"ABIA","ADAMAWA","AKWA IBOM","ANAMBRA","BAUCHI","BAYELSA","BENUE","BORNO",
"CROSS RIVER","DELTA","EBONYI","EDO","EKITI","ENUGU","FEDERAL CAPITAL TERRITORY",
"GOMBE","IMO","JIGAWA","KADUNA","KANO","KATSINA","KEBBI","KOGI","KWARA","LAGOS",
"NASARAWA","NIGER","OGUN","ONDO","OSUN","OYO","PLATEAU","RIVERS","SOKOTO",
"TARABA","YOBE","ZAMFARA"
)
# Lecture d'un onglet : on saute les lignes de titre et on nomme nous-mêmes les colonnes.
# (En R aussi, adapte skip/col_names après avoir INSPECTÉ l'onglet.)
voice <- read_excel(
fichier, sheet = "Q4 2023 Voice",
skip = 7, col_names = FALSE # à ajuster selon l'onglet observé
) |>
select(etat = 2, MTN = 3, GLO = 4, AIRTEL = 5, EMTS = 6)
# Standardisation des noms d'États (majuscules, espaces, variantes/coquilles)
canon_etat <- function(x) {
x <- str_replace_all(x, "\\s+", " ")
x <- str_to_upper(str_trim(x))
recode(x,
"AKWA I BOM" = "AKWA IBOM",
"NASSARAWA" = "NASARAWA",
"1IGAWA" = "JIGAWA",
"PIATEAU" = "PLATEAU",
"RIVERS >" = "RIVERS",
"FCT" = "FEDERAL CAPITAL TERRITORY",
.default = x
)
}
long_propre <- voice |>
# DÉPIVOTAGE : équivalent exact du melt() de pandas
pivot_longer(
cols = all_of(operateurs),
names_to = "operateur",
values_to = "nb_abonnes"
) |>
mutate(
etat = canon_etat(etat),
nb_abonnes = as.numeric(nb_abonnes),
operateur = recode(operateur, "EMTS" = "9mobile")
) |>
filter(etat %in% etats_37) |> # garder ce qui est valide (référentiel)
filter(!is.na(nb_abonnes))
# Contrôle : doit valoir 37
n_distinct(long_propre$etat)À retenir : pivot_longer(cols, names_to, values_to) en R ≡ melt(value_vars, var_name, value_name) en pandas. Même geste, même résultat. Que tu sois en Python ou en R, la démarche de nettoyage est la même : inspecter, standardiser contre un référentiel, dépivoter, contrôler.
À ce stade tu as une table propre en format long — une ligne par (État, opérateur, type de service, trimestre), des noms d'États standardisés et prêts à joindre, EMTS devenu 9mobile, plus aucune ligne parasite, et un trou de données identifié et documenté. Elle est prête pour PostgreSQL : c'est elle qui deviendra ta table de faits abonnements au moment de construire le schéma en étoile à l'étape suivante.
Étape 3 — Charger dans PostgreSQL (schéma en étoile)
Tu as maintenant deux DataFrames propres : la table de faits longue (état, opérateur, type, trimestre, nb_abonnes) issue du dépivotage des onglets télécom, et la population par État venue du CSV HDX. Ils vivent en mémoire, dans ton notebook. Le problème, c'est qu'un DataFrame en mémoire meurt à la fermeture du kernel, et qu'on ne peut pas y brancher un dashboard ni y lancer des requêtes analytiques croisées propres.
On va donc faire ce qu'on a appris aux M16-M17 : matérialiser tout ça dans un vrai PostgreSQL, organisé en schéma en étoile. Une table de faits au centre (une ligne = un couple État × opérateur × type × trimestre, avec la mesure nb_abonnes), et autour une table de dimension etats qui porte les attributs stables d'un État : sa population, son code, sa zone géopolitique. C'est ce modèle qui rendra triviales les questions du comité de direction ("pénétration par zone", "part de marché face à MTN par État") : un JOIN sur etat et c'est réglé.
On avance en quatre temps : (A) se connecter, (B) construire et charger la dimension etats, (C) construire et charger la table de faits abonnements, (D) une requête de contrôle qui prouve que la jointure ne perd aucune ligne.
A) Se reconnecter au PostgreSQL Neon
Comme aux M16-M17, on réutilise une instance PostgreSQL hébergée gratuitement sur Neon (le principe est le même avec Supabase, Railway, ElephantSQL… n'importe quel Postgres managé fait l'affaire). Tu récupères ta chaîne de connexion depuis le dashboard Neon : elle a la forme postgresql://user:password@host/dbname?sslmode=require.
Sujet. Reprends ta chaîne de connexion Neon et ouvre deux accès depuis Python : un moteur SQLAlchemy (pour le confort de to_sql / read_sql avec pandas) et une connexion psycopg2 brute (pour exécuter tes ordres SQL CREATE TABLE / UPDATE à la main). Ne mets jamais le mot de passe en clair dans le notebook : lis-le depuis une variable d'environnement.
👉 Correction
pythonimport os
from sqlalchemy import create_engine, text
# La chaîne de connexion vit dans une variable d'environnement, JAMAIS en dur
# dans le notebook (sinon elle finit sur GitHub...). Exporte-la avant de lancer
# Jupyter, ou charge-la depuis un .env avec python-dotenv.
# export DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
DATABASE_URL = os.environ["DATABASE_URL"]
# SQLAlchemy : c'est ce moteur que pandas utilisera pour to_sql / read_sql.
engine = create_engine(DATABASE_URL)
# Test de vie : si ça ne lève pas, la connexion est bonne.
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("Connexion Neon OK ✅")Deux petits points de vigilance Neon :
sslmode=requireest obligatoire, Neon refuse les connexions non chiffrées. Garde-le dans l'URL.- SQLAlchemy accepte l'URL préfixée
postgresql://. Si tu veux forcer le driver psycopg2, écrispostgresql+psycopg2://…, c'est le driver par défaut de toute façon.
Si à un moment tu veux la connexion psycopg2 brute (pour un COPY ou un executemany), tu l'ouvres à part avec la même URL :
pythonimport psycopg2
# Connexion bas niveau, utile pour COPY et pour piloter les transactions à la main.
raw_conn = psycopg2.connect(DATABASE_URL)
raw_conn.autocommit = False # on validera nous-mêmes avec commit()B) Construire et charger la dimension etats
La dimension décrit chaque État une seule fois, avec ce qui ne change pas d'un trimestre à l'autre : son code officiel (pcode), sa population, et sa zone géopolitique. La population vient du CSV HDX. La zone, elle, n'existe dans aucun fichier : c'est à toi de l'ajouter via le mapping des 37 États vers les 6 zones géopolitiques du Nigéria (rappel du contrat de données du projet).
Sujet.
- Crée la table
etats(etat TEXT PRIMARY KEY, pcode TEXT, population BIGINT, zone TEXT)dans Postgres. - En Python, construis un DataFrame
dim_etatsà partir du CSV HDX (colonnesADM1_EN,ADM1_PCODE,T_TL) : renomme, mets le nom d'État en MAJUSCULES (c'est notre clé de jointure partout dans le projet), et rattache chaque État à sa zone grâce au dictionnaire de mapping. - Charge
dim_etatsdans la table.
⚠️ Le nom d'État en MAJUSCULES est la clé de tout le schéma étoile. La dimension doit contenir exactement les noms tels que HDX les écrit (dont
FEDERAL CAPITAL TERRITORY) — c'est le côté faits qui devra s'aligner sur elle en D).
👉 Correction
Le SQL de création (exécuté via psycopg2, ou via engine — au choix) :
sqlDROP TABLE IF EXISTS etats CASCADE; -- CASCADE : on lâche aussi la FK des faits si elle existe
CREATE TABLE etats (
etat TEXT PRIMARY KEY, -- nom d'État en MAJUSCULES = clé de jointure
pcode TEXT, -- code officiel HDX (NG001 … NG037)
population BIGINT, -- population totale (T_TL) — BIGINT par prudence
zone TEXT -- une des 6 zones géopolitiques
);La construction du DataFrame en Python :
pythonimport pandas as pd
# --- Le mapping des 37 États vers les 6 zones géopolitiques (contrat de données) ---
zones = {
"North Central": ["Niger", "Kogi", "Benue", "Plateau", "Nasarawa", "Kwara",
"Federal Capital Territory"],
"North East": ["Bauchi", "Borno", "Taraba", "Adamawa", "Gombe", "Yobe"],
"North West": ["Zamfara", "Sokoto", "Kaduna", "Kebbi", "Katsina", "Kano", "Jigawa"],
"South East": ["Enugu", "Imo", "Ebonyi", "Abia", "Anambra"],
"South South": ["Bayelsa", "Akwa Ibom", "Edo", "Rivers", "Cross River", "Delta"],
"South West": ["Oyo", "Ekiti", "Osun", "Ondo", "Lagos", "Ogun"],
}
# On aplatit en dict {ÉTAT_EN_MAJUSCULES: zone} pour un .map() direct.
etat_vers_zone = {
etat.upper(): zone
for zone, etats in zones.items()
for etat in etats
}
# --- Le DataFrame de dimension à partir du CSV HDX ---
pop = pd.read_csv("pop_hdx.csv") # adapte le chemin
dim_etats = pop[["ADM1_EN", "ADM1_PCODE", "T_TL"]].copy()
dim_etats.columns = ["etat", "pcode", "population"]
# La clé de jointure : nom d'État nettoyé et EN MAJUSCULES, partout pareil.
dim_etats["etat"] = dim_etats["etat"].str.strip().str.upper()
# On rattache la zone. Si un État ne trouve pas de zone -> NaN, ça se verra tout de suite.
dim_etats["zone"] = dim_etats["etat"].map(etat_vers_zone)
# Garde-fou : aucun État ne doit rester sans zone.
assert dim_etats["zone"].notna().all(), "Un État n'a pas de zone : vérifie le mapping !"Le chargement dans Postgres avec to_sql. On a déjà créé la table (avec sa PK) juste au-dessus, donc on charge en if_exists="append" pour ne pas écraser notre schéma :
pythondim_etats.to_sql(
"etats",
engine,
if_exists="append", # la table + sa PK existent déjà, on ne fait qu'insérer
index=False,
)
print(f"{len(dim_etats)} États chargés dans la dimension.")💡 Pourquoi
appendet pasif_exists="replace"? Parce quereplacedétruit et recrée la table à partir des dtypes pandas — tu perdrais taPRIMARY KEYet tes types choisis (BIGINT). On garde toujours la main sur le schéma avec unCREATE TABLEexplicite, et pandas ne fait qu'insérer les lignes.
C) Construire et charger la table de faits abonnements
Le cœur de l'étoile. Une ligne = les abonnés d'un opérateur, pour un type de service, dans un État, à un trimestre. La mesure, c'est nb_abonnes. Et surtout : etat est une clé étrangère vers etats(etat) — c'est ce qui verrouille l'intégrité du modèle (impossible d'insérer un fait pour un État qui n'existe pas dans la dimension).
Sujet.
- Crée la table
abonnements(etat TEXT, operateur TEXT, type_service TEXT, trimestre TEXT, nb_abonnes BIGINT)avec une contrainte de clé étrangèreetat→etats(etat). - Charge dedans ton DataFrame long nettoyé (celui de l'Étape 2). Montre les deux options de chargement : le
to_sqlsimple, et leCOPY(beaucoup plus rapide sur gros volume) via psycopg2.
👉 Correction
Le SQL de création, avec la clé étrangère :
sqlDROP TABLE IF EXISTS abonnements;
CREATE TABLE abonnements (
etat TEXT NOT NULL,
operateur TEXT NOT NULL,
type_service TEXT NOT NULL, -- 'Voice' ou 'Internet'
trimestre TEXT NOT NULL, -- ex. '2023 Q4' (année d'abord : triable chronologiquement)
nb_abonnes BIGINT,
-- La FK : tout etat inséré ici DOIT exister dans la dimension.
-- Si un nom d'État n'est pas harmonisé, l'INSERT échouera : c'est voulu.
CONSTRAINT fk_etat FOREIGN KEY (etat) REFERENCES etats (etat)
);🔒 La contrainte
fk_etatest ton filet de sécurité. Si ton DataFrame de faits contient encoreFCT(que la dimension ne connaît pas sous ce nom), Postgres refusera l'insertion. Donc on harmonise le nom d'État avant de charger — c'est exactement le recodage annoncé dans le contrat de données.
python# Rappel : dans le CSV télécom, FCT s'écrit "FCT" ; dans HDX (donc dans la dimension),
# c'est "FEDERAL CAPITAL TERRITORY". On aligne les faits sur la dimension AVANT de charger.
faits = faits_long.copy() # ton DataFrame long de l'Étape 2
faits["etat"] = faits["etat"].str.strip().str.upper()
faits["etat"] = faits["etat"].replace({"FCT": "FEDERAL CAPITAL TERRITORY"})
# On écarte aussi la ligne parasite "OTHERS (UNDEFINED)" si elle traîne encore.
faits = faits[faits["etat"] != "OTHERS (UNDEFINED)"]Option 1 — chargement simple avec to_sql (parfait pour ce volume) :
pythonfaits.to_sql(
"abonnements",
engine,
if_exists="append", # la table + la FK existent déjà
index=False,
chunksize=5000, # insère par paquets, plus sûr sur une connexion distante
method="multi", # regroupe les lignes par INSERT, nettement plus rapide
)
print(f"{len(faits)} faits chargés.")Option 2 — chargement par COPY via psycopg2, l'arme lourde quand le volume grimpe (COPY court-circuite le parsing ligne à ligne de INSERT) :
pythonimport io
# On sérialise le DataFrame en CSV en mémoire (pas de fichier sur disque).
buffer = io.StringIO()
faits.to_csv(buffer, index=False, header=False)
buffer.seek(0)
with raw_conn.cursor() as cur:
cur.copy_expert(
"""
COPY abonnements (etat, operateur, type_service, trimestre, nb_abonnes)
FROM STDIN WITH (FORMAT csv)
""",
buffer,
)
raw_conn.commit() # on valide la transaction
print("COPY terminé.")⚡
to_sql(method="multi")te suffit largement ici. Retiens leCOPYpour le jour où tu chargeras des millions de lignes : c'est l'ordre de chargement le plus rapide de PostgreSQL.
D) Requête de contrôle : la jointure ne perd-elle personne ?
Un schéma en étoile ne vaut que si toutes les lignes de faits trouvent leur État dans la dimension. Sinon, un JOIN (même un INNER JOIN) fait disparaître silencieusement des abonnés — et tu sous-estimes un État sans t'en rendre compte. C'est exactement là que le recodage FCT se valide (ou se trahit s'il manque).
Sujet. Écris une requête SQL qui compte les États présents dans les faits mais absents de la dimension (un anti-join). Interprète le résultat.
👉 Correction
L'idée : un LEFT JOIN des faits vers la dimension, puis on ne garde que les lignes où le côté dimension est NULL (= pas de correspondance).
sqlSELECT COUNT(DISTINCT f.etat) AS etats_non_apparies
FROM abonnements AS f
LEFT JOIN etats AS d
ON f.etat = d.etat
WHERE d.etat IS NULL;Et si tu veux voir lesquels posent problème (pour les corriger) plutôt que juste les compter :
sqlSELECT DISTINCT f.etat AS etat_orphelin
FROM abonnements AS f
LEFT JOIN etats AS d
ON f.etat = d.etat
WHERE d.etat IS NULL
ORDER BY f.etat;Depuis Python, tu la lances et tu lis le compte :
pythoncontrole = pd.read_sql(
"""
SELECT COUNT(DISTINCT f.etat) AS etats_non_apparies
FROM abonnements AS f
LEFT JOIN etats AS d ON f.etat = d.etat
WHERE d.etat IS NULL;
""",
engine,
)
print(controle)Règle d'interprétation :
- Si
etats_non_apparies = 0: parfait. Chaque ligne de faits se raccroche à un État de la dimension, aucun abonné ne se perdra dans les jointures analytiques. Ton harmonisation des noms est complète. - Si
etats_non_apparies > 0: il reste au moins un nom d'État à harmoniser. Lance la seconde requête (celle qui liste lesetat_orphelin) pour voir lesquels, puis ajoute le recodage manquant dans lereplace({...})de l'étape C avant de recharger. Le suspect classique dans ce projet :FCTqui n'a pas été renommé enFEDERAL CAPITAL TERRITORY.
🎯 En pratique, comme la contrainte
FOREIGN KEYde l'étape C bloque déjà l'insertion de tout État inconnu, cette requête devrait renvoyer 0 dès que le chargement a réussi. Elle reste ta preuve — le contrôle qualité que tu montres au comité pour dire "aucun abonné n'a été perdu au passage en base". C'est ce réflexe (charger puis vérifier l'intégrité) qui sépare un pipeline de production d'un script jetable.
Étape 4 — Analyser en SQL avancé
Tes données sont maintenant propres, dépivotées et rangées dans PostgreSQL sous forme de schéma en étoile (le M16) : une table de faits abonnements(etat, operateur, type_service, trimestre, nb_abonnes) et une dimension etats(etat, pcode, population, zone). Parfait. Mais une table, aussi propre soit-elle, ne répond à aucune question toute seule. Le comité de direction ne veut pas voir des lignes : il veut savoir où attaquer.
C'est le moment où SQL passe de « je sélectionne des lignes » à « je raisonne sur des blocs de lignes ». Les fonctions fenêtre (OVER, PARTITION BY, RANK, LAG) et les CTE (WITH) sont exactement les outils que tu as vus au M16 : ici tu les enchaînes pour construire, brique par brique, la réponse à la question d'acquisition.
Un rappel qui vaut de l'or et qu'on réutilise dans les cinq requêtes : le trimestre est stocké au format texte '2023 Q4' (année d'abord, puis trimestre — c'est exactement ce que produit ton nettoyage à l'étape 2). Ce format n'est pas décoratif : il se trie tout seul dans l'ordre chronologique en tri alphabétique. Résultat, ORDER BY trimestre donne le bon ordre du temps, sans colonne d'ordre supplémentaire. Garde ce détail en tête, il rend LAG fiable en tâche C.
Convention pour toute l'étape : « notre opérateur » = GLO. Remplace par
'AIRTEL'ou'EMTS'si tu as choisi un autre challenger. « Le leader à déloger » = MTN.
🅰️ Tâche A — La part de marché de chaque opérateur, État par État
Le sujet. Sur le dernier trimestre disponible, pour le service voix, calcule la part de marché (%) de chaque opérateur dans chaque État. Autrement dit : dans l'État X, quel pourcentage des abonnés voix appartient à MTN, à GLO, à Airtel, à 9mobile ? Le piège classique du débutant serait de calculer un total national et de diviser tout le monde par ce total. Non : on veut le total de l'État, comparé par État. C'est le cas d'école de la fonction fenêtre SUM(...) OVER (PARTITION BY etat) : tu gardes le détail par opérateur et tu disposes, sur la même ligne, du total de son État.
👉 Correction
sqlWITH dernier AS (
-- On isole le trimestre le plus récent, une fois pour toutes
SELECT MAX(trimestre) AS trimestre
FROM abonnements
),
base AS (
-- Abonnés voix par État et par opérateur, sur ce dernier trimestre
SELECT
etat,
operateur,
SUM(nb_abonnes) AS abonnes
FROM abonnements
WHERE type_service = 'Voice'
AND trimestre = (SELECT trimestre FROM dernier)
GROUP BY etat, operateur
)
SELECT
etat,
operateur,
abonnes,
-- Le total de l'État : la fenêtre recalcule la somme pour chaque groupe d'État,
-- mais SANS écraser le détail par opérateur (contrairement à un GROUP BY)
SUM(abonnes) OVER (PARTITION BY etat) AS total_etat,
ROUND(
100.0 * abonnes / SUM(abonnes) OVER (PARTITION BY etat),
2
) AS part_marche_pct
FROM base
ORDER BY etat, part_marche_pct DESC;Comment lire le résultat. Pour chaque État, les quatre parts s'additionnent à 100 %. Repère la ligne de notre opérateur : plus part_marche_pct est basse, plus on est faible sur cet État. À l'inverse, un part_marche_pct très élevé sur la ligne MTN signale une place forte adverse. Ne conclus rien sur un seul État isolé : c'est la comparaison entre États qui parle (tâches B et E).
🅱️ Tâche B — Le classement : où sommes-nous n°1, où MTN écrase
Le sujet. La part de marché brute, c'est bien, mais le comité pense en termes de podium : « Dans combien d'États sommes-nous leader ? Deuxième ? Où MTN est-il indéboulonnable ? ». Produis, toujours sur le dernier trimestre voix, un classement des opérateurs à l'intérieur de chaque État : rang 1 pour le plus gros, rang 2 pour le suivant, etc. C'est le rôle exact de RANK() OVER (PARTITION BY etat ORDER BY nb_abonnes DESC). Ensuite, filtrer sur operateur = 'GLO' te donne directement, en une lecture, la carte de force/faiblesse de ton opérateur.
👉 Correction
sqlWITH dernier AS (
SELECT MAX(trimestre) AS trimestre FROM abonnements
),
base AS (
SELECT
etat,
operateur,
SUM(nb_abonnes) AS abonnes
FROM abonnements
WHERE type_service = 'Voice'
AND trimestre = (SELECT trimestre FROM dernier)
GROUP BY etat, operateur
)
SELECT
etat,
operateur,
abonnes,
RANK() OVER (PARTITION BY etat ORDER BY abonnes DESC) AS rang
FROM base
ORDER BY etat, rang;
-- Variante ciblée : seulement les États où NOTRE opérateur est n°1
-- (on enveloppe la requête classée dans une CTE pour pouvoir filtrer sur le rang)
WITH dernier AS (
SELECT MAX(trimestre) AS trimestre FROM abonnements
),
classement AS (
SELECT
etat,
operateur,
SUM(nb_abonnes) AS abonnes,
RANK() OVER (
PARTITION BY etat
ORDER BY SUM(nb_abonnes) DESC
) AS rang
FROM abonnements
WHERE type_service = 'Voice'
AND trimestre = (SELECT trimestre FROM dernier)
GROUP BY etat, operateur
)
SELECT etat, operateur, abonnes, rang
FROM classement
WHERE operateur = 'GLO' -- notre opérateur
ORDER BY rang, etat;💡 On ne peut pas mettre
RANK()dans unWHEREdirectement (la fonction fenêtre est calculée après leWHERE). D'où l'astuce, très fréquente en M16 : on calcule le rang dans une CTE, puis on filtre le rang dans la requête qui l'entoure.
Comment lire le résultat. Filtre operateur = 'GLO' et regarde la colonne rang : les États où rang = 1 sont tes bastions (à défendre), ceux où rang = 4 sont tes angles morts. Croise avec la version « tous opérateurs » : partout où c'est MTN qui occupe le rang = 1 avec une part très détachée, tu tiens un terrain de bataille. Ce classement ne dit pas encore où investir — un État où tu es 4ᵉ mais déjà saturé n'est pas forcément une cible. C'est la tâche E qui tranchera.
🅲️ Tâche C — La dynamique : qui monte, qui décroche (croissance QoQ)
Le sujet. Une part de marché est une photo. Le comité prépare deux ans de plan : il lui faut le film. Calcule, par État et par opérateur, le taux de croissance trimestre-sur-trimestre (QoQ) du nombre d'abonnés voix. La bonne fonction est LAG(nb_abonnes) OVER (PARTITION BY etat, operateur ORDER BY trimestre) : elle va chercher la valeur du trimestre précédent de la même série (même État, même opérateur), pour que tu puisses faire (actuel − précédent) / précédent.
Trois vigilances : (1) le PARTITION BY etat, operateur est obligatoire — sinon LAG comparerait Lagos-GLO à Kano-MTN, ce qui n'a aucun sens ; (2) l'ORDER BY trimestre s'appuie sur le format triable 'AAAA Q#' ; (3) protège la division par le premier trimestre de chaque série (qui n'a pas de précédent, donc NULL) avec NULLIF(..., 0).
👉 Correction
sqlWITH base AS (
-- Une valeur par (État, opérateur, trimestre) : la série temporelle à analyser
SELECT
etat,
operateur,
trimestre,
SUM(nb_abonnes) AS abonnes
FROM abonnements
WHERE type_service = 'Voice'
GROUP BY etat, operateur, trimestre
)
SELECT
etat,
operateur,
trimestre,
abonnes,
-- Abonnés du trimestre précédent, DANS LA MÊME série (État + opérateur)
LAG(abonnes) OVER (
PARTITION BY etat, operateur
ORDER BY trimestre
) AS abonnes_prec,
ROUND(
100.0 * (abonnes - LAG(abonnes) OVER (
PARTITION BY etat, operateur ORDER BY trimestre))
/ NULLIF(
LAG(abonnes) OVER (
PARTITION BY etat, operateur ORDER BY trimestre),
0)
, 2) AS croissance_qoq_pct
FROM base
ORDER BY etat, operateur, trimestre;⚠️ Le trou dans les données. Les trimestres présents ne sont pas parfaitement consécutifs (il peut manquer un trimestre dans la publication du NBS).
LAGregarde la ligne précédente de la série, pas « le trimestre calendaire d'avant ». Quand un trimestre manque, ta variation QoQ compare donc deux trimestres non adjacents : c'est le comportement honnête, mais signale-le dans ton interprétation plutôt que de le maquiller.
Comment lire le résultat. croissance_qoq_pct positive = la série gagne des abonnés ce trimestre-là ; négative = elle en perd ; NULL sur le premier trimestre de chaque série, c'est normal (pas de point de comparaison). Ce qui t'intéresse pour l'acquisition : les États où notre opérateur affiche une croissance QoQ régulièrement positive (une dynamique déjà lancée, à amplifier) versus ceux où MTN accélère fort (on se fait distancer). Une seule variation isolée ne prouve rien — cherche une tendance sur plusieurs trimestres.
🅳️ Tâche D — La vue vue_penetration, socle réutilisable
Le sujet. Jusqu'ici on a raisonné en parts internes au marché télécom. Mais un marché peut être petit : un État où l'on est n°1 avec 90 % de part, mais où seule une fraction de la population est abonnée, est une cible d'acquisition en or. Il faut donc rapprocher les abonnés de la population (ta dimension etats) pour obtenir le taux de pénétration = abonnés / population.
Comme cette combinaison va servir partout — le classement ci-dessus, la CTE finale, mais aussi Power BI (M17) et Streamlit (M18) — on ne la recalcule pas dix fois : on la fige dans une vue vue_penetration. C'est exactement l'esprit du M16 : la vue devient la « source de vérité » que les couches en aval réutilisent sans dupliquer la logique.
👉 Correction
sqlCREATE OR REPLACE VIEW vue_penetration AS
WITH abo_etat AS (
-- Total d'abonnés voix par État et par trimestre
-- (on agrège les 4 opérateurs : ici on veut la taille du marché, pas le détail)
SELECT
etat,
trimestre,
SUM(nb_abonnes) AS abonnes_voix
FROM abonnements
WHERE type_service = 'Voice'
GROUP BY etat, trimestre
)
SELECT
e.etat,
e.zone,
e.population,
ab.trimestre,
ab.abonnes_voix,
-- Taux de pénétration : abonnés rapportés à la population de l'État
-- (peut dépasser 100 % : multi-SIM très répandu au Nigéria — c'est attendu)
ROUND(
100.0 * ab.abonnes_voix / NULLIF(e.population, 0),
2
) AS taux_penetration_pct
FROM abo_etat ab
JOIN etats e ON e.etat = ab.etat;Tu l'interroges ensuite comme une table ordinaire, par exemple sur le dernier trimestre :
sqlSELECT etat, zone, taux_penetration_pct
FROM vue_penetration
WHERE trimestre = (SELECT MAX(trimestre) FROM vue_penetration)
ORDER BY taux_penetration_pct ASC; -- les moins pénétrés en tête = potentiel brutComment lire le résultat. Trie par taux_penetration_pct croissant : les États en tête sont les moins équipés — donc ceux où il reste le plus d'abonnés à conquérir. Ne t'étonne pas si certains taux dépassent 100 % : au Nigéria le multi-SIM (plusieurs cartes par personne) est massif, la pénétration mesure des abonnements actifs, pas des individus. Ce que tu regardes, c'est le classement relatif des États entre eux, pas la valeur absolue. Cette vue est le socle : Power BI la branchera pour ses cartes, Streamlit pour ses filtres, et la tâche E pour trancher.
🅴️ Tâche E — La requête qui répond au comité : la cible d'acquisition
Le sujet. Voilà l'aboutissement de toute l'étape — et du projet. Le comité veut une liste d'États : ceux où il faut concentrer l'effort d'acquisition. La règle business est simple à énoncer et se traduit directement en SQL :
On cible un État si (1) la part de marché de notre opérateur y est faible (on a de la place pour grandir) ET (2) la pénétration globale y est faible (le marché lui-même n'est pas saturé — il reste des abonnés à créer, pas seulement à voler à MTN).
Un État où l'on est faible mais le marché déjà saturé, c'est une guerre de tranchées coûteuse. Un État sous-pénétré où l'on est déjà leader, on le tient. L'intersection des deux faiblesses, c'est le potentiel de croissance pur. On assemble ça avec une CTE multi-niveaux qui empile les briques des tâches A et D, puis compare chaque État à la moyenne nationale (un seuil objectif, pas un chiffre arbitraire).
👉 Correction
sqlWITH dernier AS (
SELECT MAX(trimestre) AS trimestre FROM abonnements
),
abo AS (
-- Abonnés voix par État/opérateur, dernier trimestre (brique de la tâche A)
SELECT
etat,
operateur,
SUM(nb_abonnes) AS abonnes
FROM abonnements
WHERE type_service = 'Voice'
AND trimestre = (SELECT trimestre FROM dernier)
GROUP BY etat, operateur
),
parts AS (
-- Part de marché par État (fonction fenêtre, comme en A)
SELECT
etat,
operateur,
100.0 * abonnes / SUM(abonnes) OVER (PARTITION BY etat) AS part_marche_pct
FROM abo
),
notre_part AS (
-- On ne garde que la part de NOTRE opérateur
SELECT etat, part_marche_pct AS part_glo
FROM parts
WHERE operateur = 'GLO'
),
penetration AS (
-- On réutilise la vue de la tâche D (pas de recalcul !)
SELECT etat, taux_penetration_pct
FROM vue_penetration
WHERE trimestre = (SELECT trimestre FROM dernier)
),
seuil_part AS (
-- Seuil objectif n°1 : la part moyenne de notre opérateur sur tout le pays
SELECT AVG(part_glo) AS part_glo_moy FROM notre_part
),
seuil_pen AS (
-- Seuil objectif n°2 : la pénétration moyenne nationale
SELECT AVG(taux_penetration_pct) AS pen_moy FROM penetration
)
SELECT
n.etat,
e.zone,
ROUND(n.part_glo, 2) AS part_glo_pct,
ROUND(p.taux_penetration_pct, 2) AS penetration_pct
FROM notre_part n
JOIN penetration p ON p.etat = n.etat
JOIN etats e ON e.etat = n.etat
CROSS JOIN seuil_part sp
CROSS JOIN seuil_pen spen
WHERE n.part_glo < sp.part_glo_moy -- (1) on y est faible
AND p.taux_penetration_pct < spen.pen_moy -- (2) marché sous-pénétré
ORDER BY p.taux_penetration_pct ASC, -- le plus de potentiel d'abord
n.part_glo ASC;Comment lire le résultat. Chaque ligne de sortie est un État-cible : notre part y est sous la moyenne nationale et la pénétration y est sous la moyenne nationale. L'ordre de tri place en haut les États où l'on est à la fois le plus faible et le marché le moins équipé — c'est la tête de liste du plan d'acquisition. La colonne zone te permet de repérer si ces cibles se concentrent dans une même zone géopolitique (un plan régional cohérent) ou se dispersent. Si la liste ressort vide, ce n'est pas un bug : cela signifie qu'aucun État ne cumule les deux faiblesses sous la moyenne — assouplis alors la règle (par exemple un seuil au 1ᵉʳ quartile plutôt qu'à la moyenne) et documente ce choix. Ne cite jamais un État précis comme « la » réponse sans avoir regardé la dynamique (tâche C) : un État-cible qui, en plus, montre déjà une croissance QoQ positive pour notre opérateur, c'est le dossier en or à porter au comité.
🧩 Ce que tu viens de faire (et le lien avec le M16). Tu n'as pas « fait des requêtes » : tu as construit un raisonnement en couches. Fonction fenêtre pour la part de marché (A),
RANKpour le podium (B),LAGpour la dynamique (C), une vue comme source de vérité partagée (D), et une CTE multi-niveaux qui empile tout pour livrer une liste d'États actionnable (E). C'est précisément la promesse du SQL avancé du M16 : transformer une table plate en décision. La vuevue_penetrationque tu viens de créer n'est pas un cul-de-sac — elle est le point d'entrée des deux étapes suivantes, Power BI (M17) et Streamlit (M18), qui vont donner à ce raisonnement un visage visuel.
Étape 5 — Tester statistiquement
Tu as maintenant une belle carte : certaines zones ont l'air plus pénétrées que d'autres, MTN semble écraser certaines régions, ton bloc Sud paraît mieux couvert que le Nord… « Avoir l'air » ne suffit pas devant un comité de direction. Un directeur commercial peut toujours te répondre : « C'est peut-être juste le hasard de l'échantillon. » Ton job dans cette étape, c'est de transformer chaque impression visuelle en conclusion défendable, avec un test statistique et une règle de décision claire.
On reprend le jeu de données consolidé des étapes précédentes : la table de faits en format long abonnements(etat, operateur, type_service, trimestre, nb_abonnes) jointe à la dimension etats(etat, pcode, population, zone). Pour cette étape, on travaille sur une photographie (un trimestre, service Voice) pour ne pas mélanger les périodes, et on calcule pour chaque État le taux de pénétration = nb_abonnes / population ainsi que la part de marché de MTN.
⚠️ Rappel du réflexe M18. Un test statistique ne « prouve » jamais une hypothèse. Il te dit seulement : « sous l'hypothèse qu'il n'y a aucune différence (H0), à quel point mes données seraient-elles surprenantes ? ». On lit ça avec la p-value et un seuil fixé à l'avance (on prend α = 0,05). Tu ne regardes JAMAIS les chiffres avant d'avoir écrit H0/H1 — sinon tu te racontes une histoire.
Petit bloc de préparation commun aux tests qui suivent (tu l'as déjà construit aux étapes 2 à 4, on le rappelle ici).
👉 Préparation des données (Python)
pythonimport pandas as pd
import numpy as np
# --- table longue déjà nettoyée : etat, operateur, type_service, trimestre, nb_abonnes
# --- dimension états déjà jointe : etat, pcode, population, zone
# (voir étapes 2, 3 et 4). On se place sur UN trimestre et le service Voice.
# NB : le trimestre est stocké au format 'AAAA Q#' (année d'abord), tel que produit
# par le nettoyage de l'étape 2 — ici on prend le dernier trimestre disponible.
faits = abonnements.query("type_service == 'Voice' and trimestre == '2023 Q4'")
# Pénétration par État (tous opérateurs confondus)
penet = (
faits.groupby('etat', as_index=False)['nb_abonnes'].sum()
.merge(etats[['etat', 'population', 'zone']], on='etat', how='inner')
)
penet['penetration'] = penet['nb_abonnes'] / penet['population']
# Part de marché MTN par État (utile pour la corrélation, test E)
part = (
faits.pivot_table(index='etat', columns='operateur',
values='nb_abonnes', aggfunc='sum', fill_value=0)
)
part['part_mtn'] = part['MTN'] / part[['MTN', 'GLO', 'AIRTEL', 'EMTS']].sum(axis=1)
penet = penet.merge(part[['part_mtn']], on='etat')👉 Préparation des données (R)
rlibrary(dplyr)
library(tidyr)
faits <- abonnements %>%
filter(type_service == "Voice", trimestre == "2023 Q4") # format 'AAAA Q#' (cf. étape 2)
# Pénétration par État
penet <- faits %>%
group_by(etat) %>%
summarise(nb_abonnes = sum(nb_abonnes), .groups = "drop") %>%
inner_join(select(etats, etat, population, zone), by = "etat") %>%
mutate(penetration = nb_abonnes / population)
# Part de marché MTN par État
part <- faits %>%
group_by(etat, operateur) %>%
summarise(nb = sum(nb_abonnes), .groups = "drop") %>%
pivot_wider(names_from = operateur, values_from = nb, values_fill = 0) %>%
mutate(part_mtn = MTN / (MTN + GLO + AIRTEL + EMTS))
penet <- penet %>%
inner_join(select(part, etat, part_mtn), by = "etat")🅰️ Poser proprement l'hypothèse
Sujet. Avant de lancer quoi que ce soit, écris H0 et H1 pour LA question de business : « Le taux de pénétration diffère-t-il selon la zone géopolitique ? ». Note aussi le seuil que tu retiens et ce qu'un rejet (ou un non-rejet) de H0 signifierait concrètement pour le plan d'acquisition. Pas de code ici : c'est un exercice de rigueur, celui qu'on saute toujours et qu'il ne faut jamais sauter.
👉 Correction
- H0 (hypothèse nulle) : le taux de pénétration moyen est le même dans les 6 zones géopolitiques. Les écarts que tu vois sur la carte ne sont que du bruit d'échantillonnage.
- H1 (hypothèse alternative) : au moins une zone a un taux de pénétration moyen différent des autres.
- Seuil : α = 0,05, fixé avant de regarder les résultats.
- Ce que ça décide :
- Si on rejette H0 → il existe bien des zones structurellement sous- ou sur-pénétrées ; ça justifie une stratégie d'acquisition différenciée par zone (concentrer l'effort là où la pénétration est faible).
- Si on ne rejette pas H0 → on n'a pas de preuve d'un effet « zone » ; il faudra plutôt piloter État par État, sans découpage régional.
🎯 Formuler H1 comme « au moins une zone diffère » (et pas « toutes diffèrent »), c'est exactement ce que teste l'ANOVA de la question B. On garde le découpage Nord/Sud pour un test t plus ciblé (question C).
🅱️ ANOVA : la pénétration dépend-elle de la zone ?
Sujet. Tu compares une variable numérique (le taux de pénétration) entre 6 groupes (les zones). Le bon outil, ce n'est pas cinq tests t enchaînés (ça gonfle le risque d'erreur), c'est une ANOVA à un facteur. Réalise-la avec scipy.stats.f_oneway en Python et aov en R. Puis — parce que l'ANOVA te dit qu'il y a une différence mais pas lesquelles — enchaîne avec un post-hoc de Tukey pour identifier les paires de zones qui diffèrent réellement.
👉 Correction (Python)
pythonfrom scipy import stats
from statsmodels.stats.multicomp import pairwise_tukeyhsd
# ANOVA : un tableau de valeurs par zone
groupes = [g['penetration'].values for _, g in penet.groupby('zone')]
f_stat, p_value = stats.f_oneway(*groupes)
print("ANOVA — F :", f_stat, "| p-value :", p_value)
# Post-hoc : QUELLES zones diffèrent ?
tukey = pairwise_tukeyhsd(endog=penet['penetration'],
groups=penet['zone'],
alpha=0.05)
print(tukey.summary())👉 Correction (R)
r# ANOVA
modele <- aov(penetration ~ zone, data = penet)
summary(modele)
# Post-hoc de Tukey : quelles paires de zones diffèrent ?
TukeyHSD(modele, conf.level = 0.95)📏 Règle d'interprétation.
- ANOVA : si
p < 0,05, tu rejettes H0 → au moins une zone a une pénétration moyenne significativement différente. Sip ≥ 0,05, tu ne peux pas conclure à un effet de la zone (tu ne dis pas « les zones sont identiques », tu dis « je n'ai pas de preuve qu'elles diffèrent »).- Tukey : pour chaque paire de zones, regarde la p-value ajustée (colonne
p-adjen Python,p adjen R). Une paire est significativement différente si sa p-value ajustée est < 0,05 ; l'intervalle de confiance affiché ne contient alors pas 0. Ce sont ces paires-là qui te disent où est l'écart — donc quelles zones cibler en priorité.- ⚠️ L'ANOVA suppose des groupes à variance comparable et des résidus à peu près normaux ; avec 6 zones et peu d'États par zone, traite le résultat comme un signal fort à confirmer, pas comme une vérité absolue.
🅲️ Test t : le Nord est-il moins pénétré que le Sud ?
Sujet. Le comité a une intuition récurrente : « le Sud est mieux couvert que le Nord ». Teste-la. Regroupe les 3 zones North * en un bloc Nord et les 3 zones South * en un bloc Sud, puis compare les taux de pénétration moyens des deux blocs avec un test t pour échantillons indépendants (ttest_ind / t.test). Applique le réflexe vu au M18 : teste d'abord l'égalité des variances (test de Levene) et, dans le doute, utilise la version Welch du test t (qui ne suppose pas des variances égales).
👉 Correction (Python)
pythonfrom scipy import stats
import numpy as np
penet['bloc'] = np.where(penet['zone'].str.startswith('North'), 'Nord', 'Sud')
nord = penet.loc[penet['bloc'] == 'Nord', 'penetration']
sud = penet.loc[penet['bloc'] == 'Sud', 'penetration']
# 1) Les variances sont-elles comparables ? (Levene)
lev_stat, lev_p = stats.levene(nord, sud)
print("Levene — p-value :", lev_p)
# 2) Test t. equal_var=False => Welch (le choix prudent par défaut)
t_stat, t_p = stats.ttest_ind(nord, sud, equal_var=False)
print("Welch t — t :", t_stat, "| p-value :", t_p)👉 Correction (R)
rpenet$bloc <- ifelse(startsWith(penet$zone, "North"), "Nord", "Sud")
# 1) Égalité des variances (Levene, via car ; sinon var.test pour Fisher)
# install.packages("car")
car::leveneTest(penetration ~ factor(bloc), data = penet)
# 2) Test t de Welch (var.equal = FALSE est le défaut de R)
t.test(penetration ~ bloc, data = penet, var.equal = FALSE)📏 Règle d'interprétation.
- Levene : si sa p-value est ≥ 0,05, les variances sont compatibles ; < 0,05, elles diffèrent → dans les deux cas, Welch reste un choix sûr, c'est pour ça qu'on le prend par défaut.
- Test t : si
p < 0,05, tu rejettes H0 → la différence de pénétration moyenne entre Nord et Sud est statistiquement significative (regarde le signe de la différence des moyennes pour savoir quel bloc est devant). Sip ≥ 0,05, l'écart observé peut n'être que du hasard : tu ne peux pas affirmer que le Nord et le Sud diffèrent.- 🧠 « Significatif » ≠ « énorme ». Avec peu d'États par bloc, un vrai écart peut passer sous le seuil ; regarde toujours l'ampleur de la différence, pas seulement la p-value.
🅳️ Khi-deux : les opérateurs se répartissent-ils pareil dans toutes les zones ?
Sujet. Autre angle décisif face à MTN : les abonnés d'un opérateur sont-ils répartis indépendamment de la zone, ou certains opérateurs sont-ils sur-représentés dans certaines régions (ex. GLO fort au Sud-Est, MTN partout, EMTS concentré) ? C'est une question de dépendance entre deux variables catégorielles : operateur × zone. Construis le tableau de contingence des abonnés (opérateurs en lignes, zones en colonnes) et lance un test du χ² d'indépendance (chi2_contingency / chisq.test). Vérifie la condition de validité : les effectifs attendus doivent tous être ≥ 5.
👉 Correction (Python)
pythonfrom scipy import stats
# On repart de la table longue Voice + la colonne zone (jointe à etats)
faits_zone = faits.merge(etats[['etat', 'zone']], on='etat', how='inner')
# Tableau de contingence : opérateurs (lignes) x zones (colonnes)
contingence = faits_zone.pivot_table(
index='operateur', columns='zone',
values='nb_abonnes', aggfunc='sum', fill_value=0
)
chi2, p_value, dof, attendus = stats.chi2_contingency(contingence)
print("chi2 :", chi2, "| p-value :", p_value, "| ddl :", dof)
# Condition de validité : tous les effectifs attendus >= 5 ?
print("Tous les attendus >= 5 :", (attendus >= 5).all())👉 Correction (R)
rlibrary(dplyr)
library(tidyr)
faits_zone <- faits %>%
inner_join(select(etats, etat, zone), by = "etat")
contingence <- faits_zone %>%
group_by(operateur, zone) %>%
summarise(nb = sum(nb_abonnes), .groups = "drop") %>%
pivot_wider(names_from = zone, values_from = nb, values_fill = 0) %>%
tibble::column_to_rownames("operateur") %>%
as.matrix()
test <- chisq.test(contingence)
test # statistique + p-value
test$expected # effectifs attendus : tous >= 5 ?📏 Règle d'interprétation.
- Condition d'abord : vérifie que tous les effectifs attendus sont ≥ 5. Ici, avec de gros volumes d'abonnés, ce sera le cas ; si un jour ce n'était pas vrai (petit croisement), le χ² serait peu fiable — on regrouperait des catégories ou on passerait à un test exact.
- Test : si
p < 0,05, tu rejettes H0 (« opérateur et zone sont indépendants ») → la répartition des abonnés par opérateur dépend de la zone : certains opérateurs sont sur- ou sous-représentés régionalement. Pour savoir où, inspecte les résidus (test$residualsen R, ou comparecontingenceauxattendusen Python) : un résidu fortement positif = opérateur sur-représenté dans cette zone.- Si
p ≥ 0,05, tu ne peux pas conclure à une répartition régionale différenciée des opérateurs.- 🎯 Pour le comité : les résidus te montrent précisément dans quelles zones MTN domine le plus (donc où l'attaque frontale est la plus dure) et où un concurrent est déjà faible (donc où tu as une brèche).
🅴️ Corrélation : pénétration et domination de MTN vont-elles ensemble ?
Sujet. Dernière question : y a-t-il un lien entre le taux de pénétration d'un État et la part de marché de MTN dans cet État ? Autrement dit : les États où MTN écrase le marché sont-ils aussi les plus (ou les moins) pénétrés ? Mesure ce lien avec un coefficient de corrélation : Pearson (lien linéaire) et Spearman (lien monotone, robuste aux valeurs extrêmes) — pearsonr / spearmanr en Python, cor.test en R. Chaque coefficient vient avec sa p-value (le lien est-il significatif ?).
👉 Correction (Python)
pythonfrom scipy import stats
r_pearson, p_pearson = stats.pearsonr(penet['penetration'], penet['part_mtn'])
r_spearman, p_spearman = stats.spearmanr(penet['penetration'], penet['part_mtn'])
print("Pearson — r :", r_pearson, "| p-value :", p_pearson)
print("Spearman — rho :", r_spearman, "| p-value :", p_spearman)👉 Correction (R)
r# Pearson (lien linéaire)
cor.test(penet$penetration, penet$part_mtn, method = "pearson")
# Spearman (lien monotone, robuste aux outliers)
cor.test(penet$penetration, penet$part_mtn, method = "spearman")📏 Règle d'interprétation.
- Sens & force : le coefficient (
rourho) va de −1 à +1. Positif = les deux variables montent ensemble ; négatif = quand l'une monte, l'autre baisse ; proche de 0 = pas de lien monotone. Grossièrement : |coef| < 0,3 lien faible, 0,3–0,7 lien modéré, > 0,7 lien fort.- Significativité : si
p < 0,05, le lien est statistiquement significatif (peu probable d'être dû au hasard) ; sip ≥ 0,05, tu ne peux pas conclure à un lien réel.- Pearson vs Spearman : si les deux concordent, ton résultat est solide. Si Pearson et Spearman divergent nettement, c'est le signe d'une relation non linéaire ou d'outliers — fais confiance à Spearman et va regarder ton nuage de points.
- 🚨 Corrélation ≠ causalité (on creusera ça au M20). Même une corrélation forte et significative ne prouve pas que la domination de MTN cause la pénétration (ou l'inverse) : une troisième variable — l'urbanisation, le PIB de l'État — peut tirer les deux en même temps. Tu présentes un lien, pas un mécanisme.
🎁 Ce que cette étape apporte au comité
Tu es entré dans cette étape avec des impressions (« ça a l'air différent sur la carte », « le Nord semble en retard », « MTN paraît partout ») et tu en ressors avec des conclusions chiffrées et défendables : une différence entre zones testée (ANOVA + Tukey), un écart Nord/Sud tranché (test t de Welch), une répartition des opérateurs par zone qualifiée (χ²), et un lien pénétration ↔ domination MTN mesuré (corrélation). C'est exactement ce qui transforme une jolie carte en argument de décision : quand un directeur objectera « êtes-vous sûr que ce n'est pas du hasard ? », tu auras un test et une règle claire à opposer. C'est ça, passer de « je crois » à « je peux le montrer ».
Étape 6 — Dashboard Power BI
Tu as maintenant une base PostgreSQL propre sur Neon, avec ta vue vue_penetration qui expose déjà le calcul métier (le taux de pénétration par État, opérateur, type de service et trimestre). Le comité de direction ne va pas lire du SQL : il veut voir la réponse. C'est le rôle de Power BI — transformer ta vue en un tableau de bord qu'un∙e décideur∙se explore à la souris.
🍏 Note Mac (rappel du Module 17) : Power BI Desktop n'existe que sous Windows. Si tu es sur Mac, tu as trois options : une machine virtuelle Windows (Parallels, UTM), un PC de prêt, ou tu passes directement à l'Étape 7 (Streamlit) qui, elle, tourne partout et couvre le même besoin (carte + classement + filtres). Cette étape reste au programme parce que Power BI est l'outil le plus demandé en entreprise — mais elle n'est pas bloquante pour finir le projet.
🎯 Le sujet
En te connectant à la vue vue_penetration de ta base Neon en mode Import, construis un dashboard qui répond visuellement à la question du comité : où concentrer nos efforts d'acquisition ?
Ton dashboard doit contenir au minimum :
- Une carte choroplèthe du Nigéria (une forme par État) colorée par le taux de pénétration — pour repérer d'un coup d'œil les États « froids » (peu pénétrés) où le potentiel est le plus grand.
- Un histogramme des parts de marché par zone géopolitique — pour voir dans quelles zones ton opérateur est faible face à MTN.
- Un segment (slicer) sur le trimestre — pour rejouer l'analyse sur n'importe quel trimestre parmi les 11 disponibles.
- Deux mesures DAX : le taux de pénétration moyen, et la part de marché de ton opérateur.
🧭 Le guide
1. Se connecter à la vue (mode Import, chaîne directe)
Dans Power BI Desktop : Accueil → Obtenir les données → Base de données PostgreSQL. Renseigne le serveur et la base issus de ta chaîne Neon.
⚠️ Rappel du Module 17 : dans la console Neon, prends la chaîne de connexion directe (celle sans
-poolerdans le nom d'hôte). Le pooler est fait pour un flux de petites requêtes indépendantes ; un import Power BI, lui, tire une grosse requête d'un coup — la connexion directe est plus fiable pour ça. Choisis bien le mode Import (pas DirectQuery) : sur une base serverless qui se met en veille après 5 min, DirectQuery interrogerait la base à chaque clic sur un visuel et souffrirait des réveils à froid ; Import copie les données une fois en mémoire, et tout devient instantané ensuite.
Dans la fenêtre de navigation, coche vue_penetration — pas les tables brutes abonnements et etats. C'est tout l'intérêt d'avoir préparé la vue en SQL : Power BI la traite comme une simple table, avec le taux déjà calculé et la zone déjà jointe, sans que tu aies à refaire la logique de jointure côté Power BI. Clique sur Charger (patiente quelques secondes si ta base venait de se réveiller).
2. La carte choroplèthe
Power BI reconnaît les noms de lieux géographiques. Insère un visuel Carte choroplèthe (Shape map) ou Carte (Map) :
- Emplacement : la colonne
etat. Pour que Power BI place correctement les États nigérians, précise le type de données géographique deetat(onglet Modélisation → Catégorie de données → State or Province), et au besoin ajoute une colonne « pays » constante"Nigeria"pour lever toute ambiguïté. - Couleur de la saturation / valeur : ta mesure de pénétration moyenne (voir plus bas).
👉 Astuce si la carte ne trouve pas les États
La carte Bing intégrée géocode par nom : « LAGOS », « KANO », etc. passent bien, mais un FCT recodé peut être ambigu. Deux parades :
- Crée une colonne calculée qui remet le nom complet pour l'affichage cartographique :
daxEtat_carte =
IF(vue_penetration[etat] = "FCT", "Federal Capital Territory", vue_penetration[etat])- Ou, pour un rendu propre et hors-ligne, importe un fond de carte TopoJSON des États du Nigéria dans le visuel Shape map (Format → Shape → Add map). Le nom de chaque forme dans le TopoJSON doit correspondre à ta colonne
etat.
3. L'histogramme des parts de marché par zone
Insère un histogramme empilé (Stacked column chart) :
- Axe X :
zone - Valeurs :
nb_abonnes(agrégation Somme) - Légende :
operateur
Chaque colonne = une zone, découpée par opérateur → tu vois immédiatement dans quelles zones la barre MTN écrase la tienne, et où tu es au coude-à-coude.
4. Le segment sur le trimestre
Insère un visuel Segment (Slicer) et glisse trimestre dedans. Règle-le en liste déroulante ou en curseur. Tous les autres visuels se recalculent quand tu changes de trimestre — c'est ce qui te permet de raconter l'évolution sur les 11 trimestres. Ajoute si tu veux un second segment sur type_service (Voice / Internet).
5. Les mesures DAX
Clic droit sur vue_penetration → Nouvelle mesure.
daxPénétration moyenne = AVERAGE(vue_penetration[taux_penetration])Cette mesure se recalcule selon les filtres actifs : filtre sur une zone, sur un trimestre ou clique un État sur la carte, et la carte-indicateur affiche la pénétration moyenne de ce contexte-là.
👉 La part de marché de notre opérateur (2ᵉ mesure)
Remplace "GLO" par ton opérateur (GLO, AIRTEL ou EMTS/9mobile) :
daxPart de marché GLO =
DIVIDE(
CALCULATE(SUM(vue_penetration[nb_abonnes]), vue_penetration[operateur] = "GLO"),
SUM(vue_penetration[nb_abonnes])
)CALCULATE restreint le numérateur aux abonnés de ton opérateur, le dénominateur reste le total (tous opérateurs confondus) dans le contexte de filtre courant. DIVIDE évite l'erreur de division par zéro si un contexte se retrouve sans abonné. Formate la mesure en pourcentage (Modélisation → Format → %). Pose-la dans une carte à côté du filtre de zone : tu lis alors ta part de marché zone par zone.
🔗 Pont avec le storytelling (Module 20) : ce dashboard n'est pas la conclusion, c'est ton support de démonstration. Les États froids sur la carte + les zones où ta barre est courte face à MTN = les deux visuels qui portent ta recommandation. Garde-les en tête pour la restitution finale.
Étape 7 — Application Streamlit
Power BI est parfait en interne, mais il est Windows-only et vit dans un fichier .pbix. Pour partager ton analyse avec n'importe qui via un simple lien — un∙e collègue sur Mac, un∙e décideur∙se sur son téléphone — tu vas emballer la même analyse dans une petite application web avec Streamlit (Module 19), puis la publier gratuitement sur Streamlit Community Cloud.
C'est la brique qui referme la boucle du niveau : le repo Git que tu as ouvert au Module 13 devient le dépôt depuis lequel ton app se déploie en un clic.
🎯 Le sujet
Écris une application app.py qui :
- Se connecte à ta base Neon et lit la vue
vue_penetration(ou, en repli, lit un CSV exporté de cette vue). - Affiche le classement des États par pénétration (le graphique qui pointe les États sous-exploités) et un tableau de détail.
- Offre des filtres interactifs : trimestre, type de service, opérateur, zone.
- Ne met jamais la chaîne de connexion en dur dans le code : elle passe par les secrets Streamlit.
Puis publie l'app sur Streamlit Community Cloud (push GitHub → share.streamlit.io → déploiement), en gérant la chaîne de connexion via les secrets de la plateforme.
ℹ️ Streamlit reste Python uniquement ici — il n'y a pas d'équivalent R 1:1 (Shiny suit un paradigme trop différent pour une comparaison directe), comme on l'a noté au Module 19.
🧱 Le squelette de l'app
👉 Correction — app.py
pythonimport streamlit as st
import pandas as pd
from sqlalchemy import create_engine, text
st.set_page_config(page_title="Acquisition abonnés — Nigéria", layout="wide")
# --- Connexion (la chaîne vient des secrets, jamais en dur) ---
@st.cache_resource
def get_engine():
return create_engine(st.secrets["db_url"])
# --- Chargement filtré, mis en cache 10 min pour ménager la base serverless ---
@st.cache_data(ttl=600)
def charger_donnees(trimestre, type_service):
requete = text("""
SELECT etat, zone, population, operateur, nb_abonnes, taux_penetration
FROM vue_penetration
WHERE trimestre = :trimestre
AND type_service = :type_service
""")
with get_engine().connect() as conn:
return pd.read_sql(requete, conn, params={
"trimestre": trimestre,
"type_service": type_service,
})
@st.cache_data(ttl=600)
def options():
with get_engine().connect() as conn:
trims = pd.read_sql(
text("SELECT DISTINCT trimestre FROM vue_penetration ORDER BY trimestre"),
conn)["trimestre"].tolist()
zones = pd.read_sql(
text("SELECT DISTINCT zone FROM vue_penetration "
"WHERE zone IS NOT NULL ORDER BY zone"),
conn)["zone"].tolist()
return trims, zones
st.title("📡 Où concentrer notre acquisition d'abonnés ?")
st.caption("Opérateur : GLO · sources : NBS (télécom) + HDX (population)")
trims, zones = options()
# --- Filtres interactifs (barre latérale) ---
with st.sidebar:
st.header("Filtres")
trimestre = st.selectbox("Trimestre", trims, index=len(trims) - 1)
type_service = st.selectbox("Type de service", ["Voice", "Internet"])
operateur = st.selectbox("Opérateur", ["GLO", "MTN", "AIRTEL", "EMTS"])
zone = st.selectbox("Zone géopolitique", ["Toutes"] + zones)
df = charger_donnees(trimestre, type_service)
# --- Garde-fou : une combinaison de filtres peut ne rien renvoyer ---
if df.empty:
st.warning("Aucune donnée pour cette combinaison. "
"Change de trimestre ou de type de service.")
st.stop()
df_op = df[df["operateur"] == operateur].copy()
if zone != "Toutes":
df_op = df_op[df_op["zone"] == zone]
# --- Indicateurs clés ---
col1, col2 = st.columns(2)
col1.metric("Pénétration moyenne", f"{df_op['taux_penetration'].mean():.1%}")
col2.metric("États couverts", df_op["etat"].nunique())
# --- Classement des États par pénétration (les plus bas = cibles d'acquisition) ---
st.subheader(f"États classés par pénétration — {operateur}")
classement = (df_op[["etat", "taux_penetration"]]
.sort_values("taux_penetration", ascending=True)
.set_index("etat"))
st.bar_chart(classement)
# --- Détail État par État ---
st.subheader("Détail par État")
st.dataframe(
df_op[["etat", "zone", "population", "nb_abonnes", "taux_penetration"]]
.sort_values("taux_penetration"),
use_container_width=True,
)À retenir sur ce code :
st.selectboxcrée chaque filtre ; le tri dubar_chartpar pénétration croissante fait remonter en haut les États les moins pénétrés — visuellement, tes cibles d'acquisition.- La requête est paramétrée (
:trimestre,:type_service+params={...}) : jamais de f-string qui concatène une valeur utilisateur dans du SQL (réflexe anti-injection). @st.cache_resourcegarde un seul moteur de connexion pour toute l'app ;@st.cache_data(ttl=600)met en cache les résultats 10 min — utile face au scale-to-zero de Neon, ça évite de réveiller la base à chaque interaction.- Le garde-fou
if df.empty: st.stop()gère proprement le cas réel où un filtre ne renvoie rien.
👉 Variante sans base : lire un CSV exporté de la vue
Si tu ne veux pas exposer ta base (ou éviter les réveils Neon), exporte la vue une fois en CSV et fais lire ce fichier par l'app.
Export (à lancer une fois, en local) :
pythonimport pandas as pd
from sqlalchemy import create_engine
engine = create_engine("postgresql://user:pwd@host/db?sslmode=require")
df = pd.read_sql("SELECT * FROM vue_penetration", engine)
df.to_csv("vue_penetration.csv", index=False)Puis, dans app.py, remplace la connexion par une simple lecture (le CSV est commité dans le repo à côté de app.py) :
python@st.cache_data
def charger():
return pd.read_csv("vue_penetration.csv")
df = charger()Le reste de l'app (filtres, graphique, tableau) ne change pas. C'est plus simple à déployer, au prix de données figées à la date de l'export.
🔒 Les secrets, jamais la chaîne en dur
En local, crée le fichier .streamlit/secrets.toml (et ajoute-le à ton .gitignore — ce fichier ne doit jamais partir sur GitHub) :
toml# .streamlit/secrets.toml (LOCAL, à ne PAS committer)
db_url = "postgresql://user:motdepasse@ton-hote.neon.tech/nomdb?sslmode=require"st.secrets["db_url"] lit cette valeur. C'est le prolongement direct de la gestion des secrets vue au Module 19 (les secrets GitHub Actions) : le principe est le même — le code est public, la chaîne de connexion reste privée.
👉 Le .gitignore et le requirements.txt à ne pas oublier
Ajoute à ton .gitignore :
gitignore.streamlit/secrets.tomlEt crée un requirements.txt à la racine du repo, pour que Streamlit Cloud sache quoi installer :
streamlit
pandas
sqlalchemy
psycopg2-binary(La variante CSV n'a pas besoin de sqlalchemy ni de psycopg2-binary.)
🚀 Publier sur Streamlit Community Cloud
C'est ici que la boucle Git → déploiement se referme.
Pousse ton projet sur GitHub (le réflexe du Module 13). Le repo doit contenir au minimum
app.pyetrequirements.txt— et surtout PASsecrets.toml.bashgit add app.py requirements.txt .gitignore git commit -m "App Streamlit : dashboard pénétration télécom Nigéria" git push origin mainVa sur share.streamlit.io et connecte-toi avec ton compte GitHub.
Clique New app, choisis ton repo, la branche (
main) et le fichier principal (app.py).Reporte tes secrets dans la plateforme : avant (ou après) le déploiement, ouvre Advanced settings → Secrets (ou, une fois l'app créée, ⋮ → Settings → Secrets) et colle le même contenu que ton
secrets.tomllocal :toml
db_url = "postgresql://user:motdepasse@ton-hote.neon.tech/nomdb?sslmode=require"Streamlit Cloud injecte ces valeurs dans
st.secretsau moment de l'exécution — la chaîne n'est jamais dans le code.Clique Deploy. En une minute, tu obtiens une URL publique du type
https://ton-app.streamlit.app— que tu peux envoyer telle quelle au comité de direction.
💡 Mise à jour continue : à chaque
git pushsur ta branche, Streamlit Cloud redéploie automatiquement. Ton app reste synchronisée avec ton code, sans manip supplémentaire — exactement l'esprit du versioning ouvert au Module 13.
👉 Deux pièges classiques au déploiement
ModuleNotFoundErrorsur Streamlit Cloud : tonrequirements.txtest incomplet ou absent. En local ça marchait grâce à ton environnement ; sur le cloud, tout ce qui n'est pas listé n'est pas installé. Pour Postgres, n'oublie paspsycopg2-binary.- La base ne répond pas / timeout au premier chargement : c'est le scale-to-zero de Neon (veille après 5 min). La base se réveille en quelques centaines de millisecondes à quelques secondes ; le cache
@st.cache_data(ttl=600)limite la fréquence des réveils. Si c'est trop gênant pour une démo, bascule sur la variante CSV.
🔗 Bouclage du niveau : tu es parti d'un classeur Excel sale au Module 14, tu l'as nettoyé, chargé dans Postgres (M16), analysé statistiquement (M18), visualisé dans Power BI (M17) — et tu le publies maintenant en une app web partageable, versionnée sur le Git ouvert au M13. C'est un pipeline complet de bout en bout, exactement ce qu'on attend d'un∙e Data Analyst junior. Il ne te reste qu'à en tirer l'histoire (Module 20) pour la restitution finale.
Étape 8 — Restituer au comité
Tu as tout : les données récupérées et jointes, un schéma étoile propre dans Postgres, des requêtes SQL qui sortent les États sous-pénétrés et ceux où MTN domine, un test stat qui confirme (ou non) tes écarts, un dashboard et une app Streamlit en ligne. Mais rien de tout ça ne vaut quoi que ce soit tant que le comité de direction n'a pas compris et adopté ta recommandation. C'est le job du Data Analyst : transformer une analyse en décision.
Le comité ne lira pas ton notebook. Il ne veut pas ton pipeline. Il veut une réponse à sa question : « Où concentre-t-on l'acquisition sur les 2 prochaines années ? » — et une raison de te faire confiance. Cette dernière étape, c'est le M20 (storytelling) appliqué pour de vrai.
🎯 Ta tâche
Prépare la restitution de ta recommandation d'acquisition au comité de direction. Tu ne produis pas 40 slides : tu construis un message, trois arguments, des preuves en réserve. Concrètement :
Formule ton message principal en UNE phrase (le sommet de la pyramide). C'est ta recommandation, pas ta méthode. De la forme : « Concentrer l'acquisition sur [telles zones / tels États] parce qu'ils sont sous-pénétrés et que MTN y est vulnérable. » Ce message sort de tes chiffres — je ne te le donne pas.
Applique le Pyramid Principle (M20) : le message d'abord, puis 3 arguments qui le soutiennent, chacun adossé à une analyse que tu as déjà faite.
Rends tes 3 arguments MECE (Mutually Exclusive, Collectively Exhaustive) : pas de recouvrement entre eux, et ensemble ils suffisent à tenir la reco. Un découpage qui marche souvent : (a) le potentiel (où est le gisement d'abonnés non captés → taux de pénétration vs population), (b) la vulnérabilité du concurrent (où MTN est faible / où l'écart avec MTN est le plus rattrapable → parts d'opérateurs par État + ton test stat), (c) la dynamique (où la tendance sur les 11 trimestres joue pour nous → évolution trimestrielle). À toi d'ajuster selon ce que tes données racontent.
Prépare le SCQA comme ossature de ton intro (voir le gabarit plus bas).
Garde les preuves en backup : le dashboard, la carte de pénétration, l'app Streamlit, les tables SQL détaillées ne passent PAS dans le corps de la présentation. Ils sont en annexe, prêts à être dégainés si une question les appelle. Un bon storytelling cache la complexité et la sort à la demande.
🤖 Utilise l'IA pour blinder ta reco (les 2 techniques du M20)
Avant de passer devant le comité, fais tourner ta reco contre une IA — exactement comme vu au M20 :
- Technique 1 — Challenger la reco. Colle ton message + tes 3 arguments à l'IA et demande-lui de jouer l'avocat du diable : « Voici ma recommandation et mes arguments. Où sont les failles ? Qu'est-ce qu'un directeur commercial sceptique attaquerait en premier ? Mes arguments sont-ils vraiment MECE ? » Note chaque faille et corrige avant la réunion, pas pendant.
- Technique 2 — Simuler les questions du comité. Demande à l'IA de se faire passer pour un comité de direction télécom : « Génère les 8 questions les plus dures qu'un COMEX me posera après cette reco. » Tu verras arriver les classiques — « et le coût d'acquisition dans ces zones ? », « pourquoi pas les grands marchés urbains où le volume est là ? », « ta pénétration, c'est des abonnements ou des personnes uniques ? » — prépare une réponse courte à chacune. Une objection anticipée est une objection désamorcée.
📐 Gabarit SCQA à remplir (c'est une FORME, pas un contenu)
Recopie cette structure et remplis-la avec tes conclusions :
- Situation — Le marché télécom nigérian est dominé par MTN ; en tant que [GLO / Airtel / 9mobile], nous préparons notre plan d'acquisition pour les 2 prochaines années.
- Complication — Notre effort d'acquisition est aujourd'hui dispersé / mal ciblé ; toutes les zones ne se valent pas, et certaines où nous perdons du terrain sont justement celles où le potentiel est le plus grand.
- Question — Où concentrer nos ressources d'acquisition pour maximiser la croissance d'abonnés ?
- Réponse (= ton message principal) — [ta recommandation en une phrase, issue de tes analyses] : cibler [zones/États], pour [telle raison de potentiel], [telle raison de vulnérabilité MTN], [telle raison de dynamique].
Puis tu déroules les 3 arguments. C'est tout. Message → 3 arguments → preuves en réserve. Si tu tiens ça, le comité repart avec une décision, pas avec une liste de chiffres.
👉 Correction — la démarche (pas les chiffres)
Il n'y a pas de « bonne réponse » unique ici : la reco dépend de tes résultats. Ce qui est corrigé, c'est la méthode. Une restitution solide fait ceci :
1. Elle part de la réponse, pas des données. La toute première phrase prononcée est la recommandation. Le comité doit pouvoir décider même s'il n'écoute que 30 secondes. On ne raconte JAMAIS le pipeline (« d'abord j'ai récupéré le XLSX du NBS, puis l'API HDX… ») — ça, c'est ta cuisine, pas leur repas.
2. Chaque argument est adossé à une analyse traçable. Pour chaque point, tu dois pouvoir répondre « d'où sort ce chiffre ? » en une phrase :
- Argument potentiel → ta requête de taux de pénétration
nb_abonnes / populationpar État/zone (schéma étoile : faitsabonnements⋈ dimensionetats). - Argument vulnérabilité MTN → ta requête de parts d'opérateurs par État, + le test statistique qui confirme que l'écart observé n'est pas dû au hasard.
- Argument dynamique → ton analyse de l'évolution sur les 11 trimestres (Q2 2021 → Q4 2023).
3. Elle applique la règle d'interprétation stat honnêtement. Rappel de la règle générique vue au niveau : si p < 0,05, tu rejettes H0 et tu peux affirmer au comité qu'il existe une différence réelle entre les zones/opérateurs comparés ; si p ≥ 0,05, tu ne peux PAS conclure à une différence — et tu ne la présentes pas comme un fait. Ne survends jamais un écart non significatif : un COMEX qui découvre plus tard que ton « argument » ne tenait pas statistiquement ne te refera plus confiance.
4. Elle distingue abonnements et personnes. Point d'honnêteté que l'IA soulèvera (technique 2) : les données sont des abonnements actifs, pas des individus uniques (un même Nigérian peut avoir 2-3 SIM). Ton « taux de pénétration » peut donc dépasser 100 % dans certains États — c'est normal, c'est une intensité d'usage, pas un taux d'équipement. Dis-le au comité avant qu'il ne le remarque : ça renforce ta crédibilité au lieu de la casser.
5. Elle garde les preuves en réserve. Le corps = message + 3 arguments. Le dashboard, l'app Streamlit publiée, les tables détaillées, la méthodo du pipeline = annexes, sorties uniquement en réponse à une question. Structure recommandée de la présentation :
Slide 1 : LA reco (message principal, une phrase) ← le sommet
Slide 2 : Argument 1 — le potentiel (1 visuel max)
Slide 3 : Argument 2 — MTN vulnérable (1 visuel max)
Slide 4 : Argument 3 — la dynamique (1 visuel max)
Slide 5 : Prochaines étapes / ce qu'on décide aujourd'hui
── ANNEXES (on n'y va que si on te le demande) ──
A1 : dashboard complet A2 : app Streamlit (lien live)
A3 : détail par État A4 : méthodo & sources (NBS, HDX)6. Elle a été challengée avant la réunion. Tu es passé par les 2 techniques IA du M20 : tu connais les 3 failles principales de ta reco et les 5-8 questions dures qui vont tomber, et tu as une réponse courte pour chacune. Le comité ne doit pas te surprendre.
Si ta restitution coche ces 6 points, tu as fait le travail d'un Data Analyst — pas celui d'un exécutant qui livre des chiffres, celui d'un partenaire qui livre une décision.
✅ Grille d'évaluation
Auto-évalue ton projet (ou fais-le évaluer par un pair / l'IA). Chaque étape vaut des points ; les critères sont qualitatifs — c'est la solidité de ta démarche qui compte, pas un chiffre à retrouver. Total sur 100.
| # | Étape | Ce qu'on regarde | Points |
|---|---|---|---|
| 1 | Récupération + API | Le XLSX NBS est chargé ; l'API CKAN de HDX est réellement appelée (header User-Agent géré, gestion du 403) ; les deux sources arrivent en DataFrame sans intervention manuelle. | /12 |
| 2 | Nettoyage | Les onglets Voice/Internet sont dépivotés en format long propre ; header éclaté (L5-7) géré ; lignes parasites (OTHERS (UNDEFINED), opérateurs à zéro) écartées ; incohérence de nommage des onglets 2021 vs 2022-2023 absorbée ; types cohérents. |
/15 |
| 3 | Schéma + chargement Postgres | Schéma étoile correct (faits abonnements + dimension etats avec pcode, population, zone) ; jointure télécom ⋈ population réussie (recodage FCT → FEDERAL CAPITAL TERRITORY, 37 États raccordés) ; mapping des 6 zones géopolitiques intégré ; chargement reproductible. |
/15 |
| 4 | SQL avancé | Requêtes qui répondent VRAIMENT à la question business : taux de pénétration par État/zone, parts d'opérateurs, écart vs MTN ; usage de jointures + agrégations + fonctions fenêtre (ou CTE) là où c'est pertinent. | /15 |
| 5 | Statistiques | Un test adapté à la question, hypothèses H0/H1 explicites, règle de décision (p < 0,05) appliquée honnêtement ; interprétation prudente (pas de sur-conclusion sur un écart non significatif). | /12 |
| 6 | Dashboard | Visualisations lisibles et orientées décision (carte/pénétration, comparaison opérateurs, tendance trimestrielle) ; pas de graphique gadget ; titres qui portent un message. | /10 |
| 7 | App Streamlit publiée | L'app est en ligne et accessible par URL ; interactive (filtre par État/zone/opérateur) ; elle sert la question business, pas juste un tableau brut. | /11 |
| 8 | Restitution storytelling | Message principal en une phrase ; 3 arguments MECE adossés aux analyses ; SCQA/Pyramid appliqués ; preuves en backup ; reco challengée via IA (failles + questions anticipées). | /10 |
| TOTAL | /100 |
Barre de réussite : 70/100. En dessous, repère l'étape la plus faible et repasse dessus — un pipeline vaut sa maille la plus fragile. Une reco brillante posée sur un nettoyage bâclé ne tient pas ; un nettoyage parfait sans restitution ne sert à personne.
🚀 Pour aller plus loin
Tu veux pousser le projet au-delà du capstone ? Trois pistes, de la plus simple à la plus ambitieuse :
- Étends la fenêtre temporelle. Le NBS publie chaque trimestre. Va chercher les classeurs plus récents (ou les plus anciens) et empile-les dans ta table de faits : plus de trimestres = tendances plus fiables, saisonnalité visible, et tu peux commencer à parler projection au comité au lieu de simple photo.
- Ajoute la dimension revenu (ARPU). Le nombre d'abonnés ne dit pas tout : 100 000 abonnés à faible ARPU ne valent pas 100 000 abonnés premium. Si tu trouves des données de revenu moyen par abonné (rapports opérateurs, régulateur NCC), croise-les avec ta pénétration : ta reco d'acquisition passe de « où y a-t-il des gens » à « où y a-t-il de la valeur ». C'est le vrai langage d'un COMEX.
- Compare à un autre marché. Rejoue tout le pipeline sur un pays de l'UEMOA (Sénégal, Côte d'Ivoire…) où les régulateurs publient des stats télécom similaires. Un benchmark inter-pays te dit si le taux de pénétration nigérian est « haut » ou « bas » dans l'absolu, et repositionne ta reco dans un contexte régional.
🎓 Félicitations — tu as terminé le Niveau Intermédiaire
Arrête-toi un instant sur le chemin parcouru. Au M13, tu faisais ton premier git init et ton premier commit, un peu intimidé. Aujourd'hui, tu viens de livrer un pipeline de données complet, de bout en bout :
tu es allé chercher deux sources hétérogènes (un XLSX bancal du NBS, une API HDX capricieuse) → tu les as nettoyées et modélisées en schéma étoile → tu les as chargées dans PostgreSQL → tu as répondu à une vraie question business en SQL avancé → tu l'as confirmée statistiquement → tu l'as visualisée dans un dashboard → tu l'as publiée dans une app Streamlit accessible au monde entier → et tu l'as restituée au comité comme un Data Analyst, pas comme un exécutant.
Ça, ce n'est pas un exercice d'école. C'est exactement le travail d'un Data Analyst en poste. Tu peux le mettre sur ton portfolio, le montrer en entretien, le déployer comme preuve de compétence : « Voilà ce que je sais faire, de la donnée brute à la décision. »
Et tu as acquis le réflexe le plus important du métier : ne jamais confondre le pipeline avec la réponse. Le comité ne t'a pas payé pour un notebook — il t'a payé pour savoir où concentrer l'acquisition. Tout le reste n'est que plomberie au service de cette phrase.
La suite ? Le Niveau Avancé. Jusqu'ici tu as décrit et expliqué le passé (ce qui s'est passé, pourquoi). Le niveau avancé attaque le prédictif : le Machine Learning pour anticiper quels États vont décrocher, l'ACP et les techniques de réduction de dimension pour démêler des dizaines de variables d'un coup, et des modèles qui ne répondent plus seulement à « où sommes-nous faibles ? » mais à « où le serons-nous demain ? ». Tu as maintenant les fondations — la donnée, le SQL, les stats, la restitution — pour que le ML soit un outil de plus, et pas une boîte noire.
Tu es parti de zéro. Tu n'y es plus. Bienvenue chez les Data Analysts. 🚀