Cours Python · Module 8: Data science
Les statistiques - les maths qui distinguent une piste d'un hasard
Dans cette leçon17
Dernier jour de la saison. Tu as dans ton carnet le nombre d'animaux relevé à dix points d'eau, et la direction de la réserve te réclame un seul chiffre : combien d'animaux par point d'eau. Dix observations doivent se ramener à un nombre unique dans le rapport, et selon celui que tu choisis, ce rapport dira la vérité ou racontera n'importe quoi.
La deuxième question est encore pire. Vous avez pesé les lions du nord et ceux du sud de la réserve, huit de chaque côté. Le nord est ressorti plus lourd de vingt et un kilos. Est-ce que les lions du nord sont vraiment plus gros, ou est-ce que vous êtes simplement tombés sur huit costauds ? Le regard ne tranchera pas ça. Moi, pendant trois saisons, j'étais persuadé que les guépards de la vallée ouest couraient plus vite, et il a fallu poser le calcul sur le papier pour voir que l'écart tenait tout entier dans la dispersion ordinaire des mesures.
Les statistiques, c'est la boîte à outils de ces deux questions : comment résumer un paquet de nombres en quelques valeurs sensées, et est-ce que ce que tu vois n'est pas dû au hasard. Dans cette leçon on parcourt les deux, de la moyenne du carnet jusqu'à la droite qui prédit la saison prochaine.
D'où vient stats
Deux bibliothèques suffisent pour tout ce qui suit. Tu connais déjà la première : NumPy range les mesures dans des tableaux et calcule vite dessus, et l'usage veut qu'on l'abrège en np. La seconde est SciPy, un gros paquet scientifique bâti sur NumPy, où logent les intégrales, l'optimisation, le traitement du signal et, ce qui nous intéresse aujourd'hui, le module statistique. Ce module s'appelle stats et il vit à l'intérieur du paquet scipy, exactement comme pyplot vit à l'intérieur de Matplotlib.
Puisque stats est à l'intérieur, on va le chercher avec l'écriture from ... import ..., et l'ordre des quatre mots est figé par la grammaire de Python. D'abord from, qui veut dire "d'où on le prend". Juste après, le nom de l'endroit, donc le paquet scipy. Ensuite import, qui veut dire "fais-le entrer dans mon programme". Et en dernier le nom de ce qu'on fait entrer, donc le module stats. Au total : from, scipy, import, stats. L'ordre inverse, tentant pour quiconque a écrit du JavaScript avant, se termine sur un SyntaxError immédiat - Python ne connaît pas l'écriture "import quelque chose from quelque part" et s'arrête avant même d'exécuter la première ligne.
Il existe une seconde route correcte : import scipy.stats as stats. Elle fait exactement la même chose, mais elle est plus longue et t'oblige à fabriquer toi-même ton raccourci avec as. Je te recommande from scipy import stats, parce que c'est l'écriture de la documentation de SciPy et de pratiquement tous les exemples que tu croiseras en ligne : le code des autres te surprendra moins.
1import numpy as np
2from scipy import statsAprès ces deux lignes, rien n'a encore été calculé et rien n'a été affiché. Il ne reste en mémoire que deux étiquettes : np mène aux tableaux et aux calculs de base, stats mène aux tests, aux lois de probabilité et aux corrélations. Tout le reste de la leçon ne sera plus que des appels posés sur ces deux noms.
Un seul nombre à la place de dix
Reprenons la demande de la direction. Le résumé le plus évident d'un paquet de nombres, c'est la moyenne arithmétique : la somme de toutes les mesures divisée par leur nombre. C'est la fonction mean de NumPy qui la calcule, elle reçoit un tableau et rend une seule valeur. Les mesures elles-mêmes, je les range dans un tableau avec np.array, la fonction que tu as vue dans la leçon sur NumPy, et j'appelle ce tableau populations - c'est le nom exact que tu réutiliseras dans l'exercice pratique, alors autant le retenir tout de suite.
1populations = np.array([120, 450, 85, 200, 330, 150, 280, 95, 400, 175])
2
3print(np.mean(populations))
4# 228.5Ça donne 228.5 animaux par point d'eau, et ce nombre est calculé sans la moindre erreur. Le problème, c'est qu'il ne décrit pas la réserve. Vérifie toi-même : sous 228.5 il y a six points d'eau sur dix, au-dessus il n'y en a que quatre. La moyenne est tombée plus haut que la majorité des observations, parce que deux points d'eau bondés, avec 400 et 450 animaux, l'ont tirée vers le haut. La moyenne prend chaque nombre au sérieux de la même façon, donc chaque valeur extrême pèse réellement sur elle.
Un centre qu'on ne peut pas acheter
Puisque la moyenne se laisse tirer, il nous faut une mesure qui regarde non pas la taille des nombres mais leur position. C'est la médiane : tu ranges les mesures de la plus petite à la plus grande et tu prends celle du milieu. Quand le nombre de mesures est pair, comme nos dix, la médiane est la moyenne des deux valeurs centrales. C'est la fonction median de NumPy qui la calcule, appelée exactement comme mean.
1print(np.median(populations))
2# 187.5La réserve triée ressemble à ceci : 85, 95, 120, 150, 175, puis 200, 280, 330, 400, 450. Les deux valeurs centrales sont 175 et 200, donc la médiane vaut 187.5. L'écart avec la moyenne est de quarante et un animaux - et c'est déjà une information sur la forme des données, pas un défaut de calcul. Quand la moyenne est nettement plus haute que la médiane, c'est le signe qu'il y a dans le jeu quelques valeurs très grandes.
Quand un troupeau de gnous traverse le point d'eau
Faisons maintenant ce que la nature fait une fois par saison. Au point d'eau où l'on comptait d'habitude 450 animaux, un troupeau de gnous en migration passe, et on relève 4200 bêtes. Une seule mesure change, les neuf autres restent identiques. Voilà à quoi ressemble une valeur aberrante, outlier en anglais : une observation qui n'est pas une erreur, seulement un événement rare, et qui peut malgré tout faire exploser une statistique. Recalculons les deux centres.
1with_herd = np.array([120, 4200, 85, 200, 330, 150, 280, 95, 400, 175])
2
3print(np.mean(with_herd))
4# 603.5
5print(np.median(with_herd))
6# 187.5Arrête-toi sur ces deux lignes, c'est le moment le plus important de la leçon. La moyenne a sauté de 228.5 à 603.5, elle a presque triplé à cause d'une seule observation. La médiane, elle, n'a pas bougé d'un cheveu : 187.5 avant le troupeau et 187.5 après. La raison est simple - pour la médiane, tout ce qui compte, c'est que 4200 se trouve quelque part au bout droit de la file. Que ce soit 4200, 450 ou quatre millions, la paire centrale reste la même. C'est donc bien la médiane qui est la mesure de tendance centrale résistante aux valeurs aberrantes, et c'est elle qui part dans le rapport quand les données peuvent être bizarres.
Autant redresser tout de suite les trois confusions qui reviennent le plus souvent à cet endroit. La moyenne arithmétique est bien une mesure de tendance centrale, mais elle n'est pas résistante - tu viens de voir une seule mesure la déplacer de 375 unités. La variance et l'écart-type, eux, ne sont pas du tout des mesures de tendance centrale : ils ne disent pas où se trouve le centre, seulement à quel point les données sont dispersées. Pire, tous les deux se construisent sur des carrés d'écarts à la moyenne, donc ils réagissent aux valeurs aberrantes encore plus violemment que la moyenne elle-même. On va le chiffrer dans un instant et tu verras l'ampleur.
Le troisième centre : le mode
Les mesures de tendance centrale sont trois, alors complétons la collection. Le mode est la valeur qui revient le plus souvent. Il a du sens là où les mesures se répètent : le nombre de patrouilles par jour, le nombre de petits par portée, l'espèce vue le plus souvent au point d'eau. C'est la fonction mode du module stats qui le calcule, et elle rend un objet à deux champs : mode est la valeur elle-même, count est son nombre d'apparitions.
1patrols = np.array([3, 5, 4, 5, 2, 5, 4])
2
3result = stats.mode(patrols)
4print(result.mode)
5# 5
6print(result.count)
7# 3Le plus souvent, vous sortiez cinq patrouilles par jour, et c'est arrivé trois fois. Remarque qu'on n'a pas calculé le mode sur le tableau populations - ce n'est pas un oubli. Là-bas, les dix nombres sont tous différents, donc chacun apparaît une seule fois, et la fonction rendrait poliment le plus petit d'entre eux avec un compteur à un. Une valeur formellement juste et parfaitement inutile. Le mode se calcule là où quelque chose se répète.
À quel point les données sont dispersées
Deux réserves peuvent avoir exactement la même moyenne sans rien avoir en commun. Dans la première, chaque point d'eau rassemble environ deux cents animaux ; dans la seconde, la moitié des points sont déserts et l'autre moitié craque de partout. Pour décrire cette différence, on utilise les mesures de dispersion. La plus importante est l'écart-type : à peu près la distance typique entre une mesure isolée et la moyenne. C'est np.std qui le calcule. Sa proche parente est la variance, avec np.var, qui est exactement le carré de l'écart-type.
1print(np.std(populations))
2# 123.06603918222119
3print(np.var(populations))
4# 15145.25Pour une moyenne de 228.5, un point d'eau typique s'en écarte d'environ 123 animaux - la dispersion est énorme, et c'est ça, le vrai portrait de la réserve. La variance 15145.25 porte la même information, mais dans des unités élevées au carré, autrement dit en "animaux carrés", que personne n'arrive à se représenter. C'est pour cette raison qu'on met l'écart-type dans les rapports et qu'on laisse la variance aux formules, à l'intérieur des calculs. Retiens aussi que np.std traite par défaut les données comme la population entière ; quand tes dix points d'eau ne sont qu'un échantillon d'une réserve plus vaste, on ajoute l'argument ddof=1.
J'avais promis de montrer comment ces deux mesures encaissent le troupeau de gnous. L'écart-type de with_herd vaut 1202.87 au lieu de 123.07 - il a été multiplié par près de dix à cause d'une seule observation, alors que la médiane, elle, n'a pas bougé du tout. La preuve que la dispersion est la partie la plus fragile de la statistique descriptive, et surtout pas un abri contre les valeurs aberrantes.
Où habite la moitié du troupeau
Il existe un troisième regard sur ces mêmes données, résistant comme la médiane mais plus riche. Un percentile est la valeur sous laquelle se trouve un pourcentage donné des observations : le percentile 25 coupe le quart le plus bas des mesures, le percentile 95 dit sous quoi tient presque tout. C'est np.percentile qui les calcule, et elle prend deux arguments dans cet ordre : le tableau, puis le pourcentage voulu. Trois percentiles portent un nom à eux et s'appellent les quartiles : Q1 est le percentile 25, Q2 le percentile 50 (donc tout simplement la médiane), Q3 le percentile 75. L'écart entre Q3 et Q1 s'appelle l'écart interquartile, IQR en abrégé, et il décrit l'intervalle où habite la moitié centrale des données. La fonction iqr du module stats le calcule toute seule. Par contraste, np.ptp donne l'étendue ordinaire, c'est-à-dire la différence entre la plus grande et la plus petite mesure.
1print(np.percentile(populations, 25))
2# 127.5
3print(np.percentile(populations, 50))
4# 187.5
5print(np.percentile(populations, 75))
6# 317.5
7print(stats.iqr(populations))
8# 190.0
9print(np.ptp(populations))
10# 365La moitié centrale des points d'eau tient entre 127.5 et 317.5 animaux, et Q2 est sorti identique à la médiane calculée plus haut, parce que ce sont deux noms de la même chose. Mais le plus intéressant arrive quand tu relances ces cinq lignes sur le tableau with_herd. L'étendue explose de 365 à 4115, parce qu'elle ne dépend que des valeurs extrêmes. L'IQR reste exactement à 190.0, puisque Q1 et Q3 tombent toujours sur les mêmes mesures qu'avant. D'où la règle : quand des événements rares peuvent se cacher dans les données, décris-les avec la médiane et l'IQR, pas avec la moyenne et l'étendue.
La forme, ou de quel côté penche la distribution
Tu sais maintenant où se trouve le centre et quelle est la largeur de la dispersion. Il manque une troisième information : est-ce que les données se répartissent symétriquement autour du centre, ou est-ce qu'elles penchent d'un côté ? C'est l'asymétrie, skewness en anglais, que mesure la fonction skew. Une valeur proche de zéro signale une distribution symétrique. Une valeur positive signale une longue queue à droite, donc une poignée de mesures très hautes. Une valeur négative, une longue queue à gauche.
1print(stats.skew(populations))
2# 0.5269796953903054
3print(stats.skew(with_herd))
4# 2.6335940542443765La réserve ordinaire a une asymétrie de 0.53, une légère inclinaison vers la droite - et c'est précisément pour cette raison que la moyenne 228.5 se trouvait plus haut que la médiane 187.5. Une fois le troupeau de gnous ajouté, l'asymétrie bondit à 2.63, ce qui revient à hurler "il y a quelque chose d'extrême là-dedans". L'asymétrie est donc un détecteur rapide de valeurs aberrantes, avant même que tu commences à les chercher une par une. Il existe encore une mesure cousine, l'aplatissement de la fonction stats.kurtosis, qui décrit l'épaisseur des queues de la distribution ; je ne la cite que de nom, parce que la lire correctement demanderait une leçon entière et qu'aucune conclusion d'aujourd'hui ne repose dessus.
La loi normale, ou d'où viennent les données d'exercice
Les mesures de la nature se rangent très souvent dans la même forme : le plus gros des résultats se serre autour du centre, et plus on s'en éloigne, plus les cas se raréfient. Cette forme s'appelle la loi normale et elle ressemble à une cloche. Deux nombres la décrivent : le centre de la cloche, que NumPy et SciPy nomment loc, et la largeur de la cloche, c'est-à-dire l'écart-type, nommée scale.
Tu n'as pas toujours de vraies données sous la main, alors savoir en tirer au sort est utile. C'est le travail de np.random.normal, à qui tu passes loc, scale et size, le nombre de mesures. Le tirage a cependant un défaut : à chaque exécution il sort autre chose. np.random.seed répare ça en fixant le point de départ du générateur - une fois la graine posée, le même programme recrache toujours les mêmes nombres, et c'est pour cette raison que les résultats de cette leçon tomberont au chiffre près chez toi. Tirons la taille de deux cents girafes en centimètres, et raccourcissons les résultats avec la fonction intégrée round, qui reçoit un nombre et un nombre de décimales - sans elle, on lirait une quinzaine de chiffres après la virgule.
1np.random.seed(7)
2heights = np.random.normal(loc=500, scale=40, size=200)
3
4print(round(np.mean(heights), 2))
5# 498.88
6print(round(np.std(heights), 2))
7# 39.62On avait demandé une moyenne de 500 et un écart-type de 40, on obtient 498.88 et 39.62. Ce petit décalage n'est pas une erreur - c'est le bruit normal du tirage, exactement celui qui fait que deux équipes comptant la même réserve ne reviennent jamais avec le même résultat. Sur deux cents girafes il est faible ; sur dix, il serait bien plus gros. Ce même mécanisme a des frères pour d'autres formes : np.random.poisson tire des nombres d'événements dans un temps fixé, par exemple combien de lions tu verras en une heure, et np.random.exponential tire les intervalles de temps entre deux événements.
Ces données sont-elles vraiment normales
Beaucoup de méthodes statistiques supposent que les données viennent d'une loi normale, alors autant le vérifier plutôt que le deviner sur un graphique. C'est le rôle du test de Shapiro-Wilk, appelé par stats.shapiro. L'écriture se compose de six morceaux dans un ordre fixe : le nom du module stats, un point, le nom de la fonction shapiro, la parenthèse ouvrante, le nom des données et la parenthèse fermante. Le test rend deux valeurs d'un coup, alors on les récupère dans deux variables écrites à gauche du signe égal et séparées par une virgule : la première est la statistique du test, la seconde la p-value, c'est-à-dire la probabilité de voir de telles données si la distribution était bel et bien normale. L'usage veut qu'une p-value au-dessus de 0.05 signifie "rien ne permet de rejeter la normalité", et en dessous de 0.05 "ces données ne sont probablement pas normales".
1stat, p_value = stats.shapiro(heights)
2
3print(round(stat, 4))
4# 0.9912
5print(round(p_value, 4))
6# 0.2614La p-value vaut 0.2614, largement au-dessus du seuil de 0.05, donc les girafes passent le test - ce à quoi on s'attendait, puisque nous les avons nous-mêmes tirées d'une loi normale. Fais attention à la formulation prudente : le test n'a pas prouvé la normalité, il n'a simplement trouvé aucune raison de la rejeter. Une différence subtile, mais importante dans toute la statistique.
Voyons maintenant à quoi ressemble le résultat quand les données sont vraiment tordues. Lançons le même test sur la réserve au troupeau de gnous, et cette fois testons le seuil directement avec la comparaison p_value < 0.05, qui rend un simple True ou False.
1stat, p_value = stats.shapiro(with_herd)
2
3print(round(stat, 4))
4# 0.44
5print(p_value < 0.05)
6# TrueLa statistique est tombée de 0.99 à 0.44, et la p-value est passée sous le millionième, donc la condition p_value < 0.05 est vraie et on rejette la normalité. Rien d'étonnant - ce sont les mêmes données auxquelles l'asymétrie donnait 2.63. Deux outils différents, un même diagnostic : une seule observation déforme le jeu entier.
La différence entre deux troupeaux est-elle réelle
On revient à la deuxième question du début. Vous avez pesé huit lions dans deux parties de la réserve et vous voulez savoir si le nord élève vraiment des bêtes plus lourdes. Commençons par noter les mesures et calculer les deux moyennes avec l'outil que tu connais déjà.
1north = np.array([190, 205, 178, 212, 195, 188, 201, 199])
2south = np.array([172, 168, 185, 176, 180, 165, 178, 174])
3
4print(np.mean(north))
5# 196.0
6print(np.mean(south))
7# 174.75L'écart est de 21.25 kilos en faveur du nord. En soi, il ne veut encore rien dire : il n'y a que huit mesures par groupe, et deux lions peuvent se distinguer d'une bonne dizaine de kilos. La question est celle-ci : est-ce qu'un tel écart pourrait sortir du pur hasard si les deux troupeaux étaient en réalité identiques ?
C'est le test t, ou test de Student, qui répond, disponible sous le nom stats.ttest_ind pour deux groupes indépendants. La fonction reçoit deux tableaux et rend la statistique t ainsi qu'une p-value de même sens que pour le test de normalité : la probabilité de voir un écart aussi grand s'il n'existait aucun écart réel.
1t_stat, p_value = stats.ttest_ind(north, south)
2
3print(round(t_stat, 4))
4# 4.8177
5print(round(p_value, 6))
6# 0.000273La p-value vaut 0.000273, soit 0.03 pour cent. Si les deux troupeaux étaient identiques, un tel écart sortirait moins d'une fois sur trois mille essais - on tient donc l'écart pour réel et on peut l'inscrire dans le rapport. Remarque que les moyennes, elles, n'ont pas bougé ; le test ne recalcule rien dans les données, il ne fait qu'y ajouter un jugement de crédibilité.
Puisque le test t est un outil qu'on confond souvent avec d'autres, disons clairement ce à quoi il ne sert pas. Il ne normalise pas les données - la remise à l'échelle des mesures est une opération séparée, où tu soustrais la moyenne et divises par l'écart-type, et tu la découvriras avec le feature engineering. Il ne calcule pas de corrélation - c'est le rôle de stats.pearsonr et de stats.spearmanr, que tu vas voir dans un instant. Il ne génère pas de nombres aléatoires - ça, c'est le travail de la famille np.random, comme np.random.normal utilisée un paragraphe plus haut. Le test t a une seule mission : comparer les moyennes de deux groupes et dire si l'écart entre elles est significatif.
Quand les données ne sont pas des nombres mais des catégories
Le test t a besoin de nombres, puisqu'il calcule des moyennes. Mais que faire quand les deux variables sont des catégories ? Vous notez le moment de la sortie (matin ou soir) et le fait d'avoir vu un lion (oui ou non), et vous voulez savoir si l'heure de la journée y est pour quelque chose. Ces données se rangent dans un tableau de contingence : les lignes sont un caractère, les colonnes l'autre, et à l'intérieur le nombre de cas. C'est le test du khi-deux qui l'examine, appelé par stats.chi2_contingency, et il rend quatre choses : la statistique du khi-deux, la p-value, le nombre de degrés de liberté dof et le tableau expected, c'est-à-dire les effectifs auxquels on devrait s'attendre si les deux caractères étaient parfaitement indépendants.
1observed = np.array([[50, 30],
2 [20, 40]])
3
4chi2, p_value, dof, expected = stats.chi2_contingency(observed)
5
6print(round(chi2, 4))
7# 10.5292
8print(round(p_value, 4))
9# 0.0012
10print(dof)
11# 1Le matin, un lion a été vu 50 fois pour 30 sorties bredouilles ; le soir, l'inverse, 20 réussites pour 40 sorties bredouilles. La p-value 0.0012 est nettement sous 0.05, donc l'heure de la journée et la rencontre d'un lion ne sont pas indépendantes - les sorties du matin sont vraiment plus efficaces. Le tableau expected contiendrait les valeurs 40, 40, 30 et 30, l'image d'un monde où le moment de la journée n'aurait aucune importance ; plus les observations s'en éloignent, plus la statistique du khi-deux grimpe.
Est-ce que deux grandeurs vont de pair
La question suivante venue du terrain n'a pas la même forme que les précédentes : on ne compare plus deux groupes, on vérifie si deux grandeurs évoluent ensemble. Plus on reste longtemps à l'affût, plus on voit d'animaux - mais quelle est la force de ce lien ? C'est le coefficient de corrélation de Pearson qui la mesure, calculé par stats.pearsonr, qui reçoit deux tableaux de même longueur et rend le coefficient ainsi qu'une p-value.
Trois choses à savoir sur ce coefficient. Il tient toujours entre -1 et 1. Son signe donne le sens : positif veut dire "l'un monte, l'autre monte", négatif "l'un monte, l'autre descend". Sa valeur absolue donne la force : proche de 1, le lien est très fort ; proche de 0, il n'y a pas de lien. Et la réserve la plus importante : Pearson ne mesure que la relation linéaire, c'est-à-dire à quel point les points s'alignent le long d'une droite.
1hours = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
2seen = np.array([4, 3, 9, 12, 10, 18, 15, 21, 19, 26])
3
4corr, p_value = stats.pearsonr(hours, seen)
5
6print(round(corr, 4))
7# 0.9526
8print(round(p_value, 6))
9# 2.1e-05Le résultat affiché est 0.9526, soit 0,95 une fois arrondi. On le lit en une seule phrase : une forte corrélation linéaire positive - plus l'affût dure, plus on voit d'animaux, et les points restent près d'une droite. Écarte au passage les trois lectures fausses de ce même 0,95. Une forte corrélation négative, ce serait -0,95 : même force, sens inverse, un affût plus long signifiant moins d'animaux. Répondre "aucune corrélation" demanderait un coefficient voisin de zéro, disons entre -0,1 et 0,1, ce que 0,95 n'est manifestement pas. Et 0,95 ne dit rien d'une relation non linéaire, parce que Pearson ne mesure tout simplement pas ce genre de relation - une valeur aussi haute prouve au contraire que le lien est proche d'une droite.
Quand Pearson ne voit pas un lien pourtant évident
Voyons cette limite en direct. La population d'une certaine espèce d'insectes double à peu près chaque semaine, donc le lien entre le numéro de la semaine et l'effectif est parfait - sauf qu'il est exponentiel et non rectiligne. Pour ces cas-là, on utilise la corrélation de Spearman, appelée par stats.spearmanr. Au lieu des valeurs, elle prend leurs rangs, c'est-à-dire leur place dans la file, et elle ne pose qu'une question : quand l'un monte, est-ce que l'autre monte aussi ?
1week = np.array([1, 2, 3, 4, 5, 6, 7, 8])
2insects = np.array([2, 4, 9, 20, 45, 100, 230, 520])
3
4corr, p_value = stats.pearsonr(week, insects)
5print(round(corr, 4))
6# 0.8171
7
8rho, p_value = stats.spearmanr(week, insects)
9print(round(rho, 4))
10# 1.0Mêmes données, deux réponses différentes. Pearson dit 0.82, parce qu'une courbe exponentielle imite mal une droite. Spearman dit 1.0, donc le lien est parfait - chaque semaine donne un résultat plus élevé que la précédente, sans une seule exception. Ça ne veut pas dire que l'un des deux coefficients ment : ils mesurent des choses différentes. Pearson demande "est-ce que c'est une droite", Spearman demande "est-ce que ça monte". Quand tu soupçonnes une relation non linéaire, prends Spearman.
Ce que vaut vraiment ta moyenne
Toute moyenne calculée sur un échantillon n'est qu'une estimation. Tu as mesuré deux cents girafes, pas toutes celles du monde, donc la vraie moyenne de l'espèce se trouve quelque part près de la tienne, sans forcément tomber dessus. L'intervalle de confiance met ce "quelque part près" en chiffres : l'intervalle à 95 pour cent est la plage dont on peut affirmer avec 95 pour cent de certitude qu'elle contient la vraie moyenne. Il faut deux outils pour ça : stats.sem calcule l'erreur standard de la moyenne, c'est-à-dire de combien la moyenne d'un échantillon peut osciller, et stats.t.interval la transforme en intervalle. Ce second appel reçoit le niveau de confiance, le nombre de degrés de liberté égal au nombre de mesures moins un (la longueur du tableau est donnée par la fonction intégrée len), le centre loc et l'échelle scale. Il rend une paire de nombres : la borne basse est à l'indice 0, la borne haute à l'indice 1.
1mean = np.mean(heights)
2sem = stats.sem(heights)
3ci_95 = stats.t.interval(0.95, len(heights) - 1, loc=mean, scale=sem)
4
5print(round(sem, 3))
6# 2.808
7print(round(ci_95[0], 2), round(ci_95[1], 2))
8# 493.34 504.42Au lieu du sec "la taille moyenne d'une girafe est de 498.88 cm", tu peux maintenant écrire une phrase plus solide : la vraie moyenne se situe avec 95 pour cent de certitude entre 493.34 et 504.42 cm. La moyenne elle-même n'a pas changé - elle a seulement gagné une information honnête sur son incertitude. Si tu avais mesuré dix girafes au lieu de deux cents, l'intervalle serait bien plus large, parce qu'un petit échantillon veut dire une grande incertitude. C'est la façon la plus simple d'empêcher un rapport de faire semblant d'être plus précis qu'il ne l'est.
La régression linéaire en quatre étapes
Reste la dernière question, la plus pratique de toutes : puisque le troupeau grandit, combien d'animaux comptera-t-il la saison prochaine ? La corrélation se contente de dire "ça monte ensemble", et toi tu as besoin d'un nombre précis. C'est la régression linéaire qui le donne : l'ajustement aux points d'une droite d'équation y = a * x + b, où a est la pente (slope), c'est-à-dire de combien y augmente quand x augmente de un, et b l'ordonnée à l'origine (intercept), c'est-à-dire la valeur de y pour x égal à zéro. Dans SciPy, une seule fonction s'en charge, stats.linregress, mais le chemin jusqu'au résultat passe toujours par les mêmes quatre étapes, et il vaut mieux respecter leur ordre.
Première étape : préparer les données x et y. Sans deux tableaux parallèles, il n'y a rien à ajuster. On saisit les numéros de saison et les effectifs relevés du troupeau.
1seasons = np.array([1, 2, 3, 4, 5, 6])
2herd = np.array([34, 41, 45, 52, 58, 63])Rien n'a encore été calculé, mais les données ont désormais la bonne forme : six saisons et six mesures correspondantes, appariées dans le même ordre. C'est le seul moment où tu décides ce qui est la cause et ce qui est l'effet - x est la grandeur que tu connais, y celle que tu veux prédire.
Deuxième étape : appeler stats.linregress(x, y). La fonction reçoit les deux tableaux et rend un seul objet contenant l'ensemble des résultats.
1result = stats.linregress(seasons, herd)Et de nouveau le silence - rien ne s'est affiché, parce que le résultat a atterri dans la variable result. Elle contient cinq valeurs d'un coup : slope, intercept, rvalue, pvalue et stderr. On peut aussi les récupérer avec l'écriture plus ancienne, en dépaquetant les cinq dans des variables séparées sur une seule ligne, mais je te recommande l'objet result avec le point : à cinq valeurs, l'ordre se mélange vite, alors que result.slope se lit sans la moindre ambiguïté.
Troisième étape : lire slope et intercept, autrement dit sortir du résultat l'équation de la droite.
1print(round(result.slope, 2))
2# 5.8
3print(round(result.intercept, 2))
4# 28.53La droite a pour équation y = 5.8 * x + 28.53. En français courant : le troupeau grandit en moyenne de 5.8 animaux par saison, et avant la première saison le point de départ de l'ajustement tombait sur 28.53. En remplaçant x par la septième saison, tu obtiens la prédiction 5.8 fois 7 plus 28.53, soit environ 69 animaux.
Quatrième étape : vérifier le R-carré, parce qu'une droite s'ajuste même à un nuage de points totalement aléatoires et que personne ne viendra t'en avertir. rvalue est le coefficient de corrélation que tu connais déjà, et son carré - calculé en Python avec l'opérateur de puissance ** - s'appelle le R-carré : il dit quelle part de la variation de y ta droite explique. Un, c'est un ajustement parfait ; zéro, c'est une droite inutile. On en profite pour regarder pvalue, qui répond à la question de savoir si la pente de la droite se distingue seulement de zéro.
1print(round(result.rvalue ** 2, 4))
2# 0.9964
3print(round(result.pvalue, 6))
4# 5e-06Le R-carré vaut 0.9964, donc la droite explique plus de 99 pour cent de la variation de l'effectif du troupeau - la prédiction de 69 animaux mérite ta confiance. S'il était sorti 0.24, comme dans des données qui sautent en haut et en bas sans la moindre tendance, la valeur de slope se serait quand même calculée et aurait quand même eu l'air sérieuse, sans rien signifier du tout. C'est pour cette raison que l'ordre de ces quatre étapes n'est pas une simple convention : sans données il n'y a rien à appeler, sans appel il n'y a nulle part où lire slope et intercept, et sans R-carré tu ne sais pas si la droite que tu viens de lire décrit quoi que ce soit de réel.
Ce que tu remportes du camp
- Les mesures de tendance centrale sont la moyenne, la médiane et le mode. Celle qui résiste aux valeurs aberrantes, c'est la médiane - une seule mesure peut déplacer la moyenne de centaines d'unités, tandis que la variance et l'écart-type ne décrivent pas du tout le centre mais la dispersion, et réagissent aux extrêmes encore plus fort.
- La dispersion se décrit avec
np.stdetnp.var, et de façon plus résistante avec les quartiles etstats.iqr. L'étendue simplenp.ptpsuccombe à une seule valeur aberrante. - Le module statistique s'importe dans l'ordre
from,scipy,import,stats. L'écriture en sens inverse donne unSyntaxError. - Le test t (
stats.ttest_ind) sert à comparer les moyennes de deux groupes. Il ne normalise pas les données, ne calcule pas de corrélation et ne génère pas de nombres aléatoires. - Le test du khi-deux examine le lien entre deux caractères catégoriels à partir d'un tableau de contingence.
- Une corrélation de Pearson de 0,95 signifie une forte corrélation linéaire positive. Une forte corrélation négative, c'est -0,95 ; l'absence de corrélation, c'est autour de zéro ; et la relation non linéaire, Pearson ne la mesure pas du tout - c'est le rôle de Spearman.
- Le test de normalité s'écrit en six morceaux :
stats,.,shapiro,(,data,), et le résultat se lit sur la p-value face au seuil de 0.05. - L'intervalle de confiance, avec
stats.semetstats.t.interval, ajoute à la moyenne une mesure honnête de son incertitude. - La régression linéaire a quatre étapes dans un ordre fixe : préparer les données
xety, appelerstats.linregress(x, y), lire la pente et l'ordonnée à l'origine, vérifier le R-carré.
Les statistiques n'ajoutent pas une seule observation à ton carnet - elles te donnent seulement le droit d'affirmer au rapport du soir que la piste est réelle, et non que tu as simplement cru la voir sur le terrain.
Code de cette leçon : main.py
1# ===========================================
2# Labo Safari : Statistiques descriptives
3# ===========================================
4# Calcule les statistiques clés pour les données de suivi
5# de la faune collectées à travers la réserve du safari.
6
7import pandas as pd
8import numpy as np
9
10# --- Mesures de suivi de la faune ---
11tracking_data = {
12 "species": ["Lion", "Lion", "Lion", "Elephant", "Elephant",
13 "Elephant", "Giraffe", "Giraffe", "Giraffe",
14 "Cheetah", "Cheetah", "Cheetah"],
15 "distance_km": [12.5, 8.3, 15.7, 5.2, 3.8, 6.1,
16 9.4, 11.2, 7.8, 22.1, 18.5, 25.3],
17 "speed_kmh": [48.2, 55.0, 42.8, 12.5, 10.3, 14.8,
18 18.5, 22.1, 16.0, 95.2, 88.7, 102.3],
19 "heart_rate": [85, 92, 78, 35, 30, 38,
20 45, 50, 42, 120, 115, 125]
21}
22
23df = pd.DataFrame(tracking_data)
24
25# --- Calculer les statistiques de base ---
26print("=== Statistiques de distance ===")
27print("Distance moyenne :", df["distance_km"].mean(), "km")
28print("Distance médiane :", df["distance_km"].median(), "km")
29print("Écart-type :", df["distance_km"].std(), "km")
30
31# --- Statistiques groupées par espèce ---
32print("\n=== Vitesse moyenne par espèce ===")
33print(df.groupby("species")["speed_kmh"].mean())
34
35# TODO : Calcule le rythme cardiaque min et max
36# print("\nRythme cardiaque min :", df["heart_rate"]...)
37# print("Rythme cardiaque max :", df["heart_rate"]...)
38
39# TODO : Utilise .agg() pour obtenir la moyenne, l'écart-type et la médiane
40# de speed_kmh groupés par espèce
41# stats = df.groupby("species")["speed_kmh"].agg(...)
42# print("\n=== Statistiques de vitesse par espèce ===")
43# print(stats)
44
45# TODO : Calcule la corrélation entre la vitesse et le rythme cardiaque
46# correlation = df["speed_kmh"].corr(...)
47# print("\nCorrélation vitesse-rythme cardiaque :", correlation)
48Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Quelle mesure de tendance centrale résiste aux valeurs aberrantes ?
2. À quoi sert un test t ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Remets dans l'ordre l'import du module stats de la bibliothèque scipy :
- Éditeur de code
Calcule la moyenne, la médiane et l'écart-type pour le tableau populations.
- Tri vertical
Remets dans l'ordre les étapes pour réaliser une régression linéaire avec scipy :
- Clique dans l'ordre
Clique sur les boutons pour effectuer un test de Shapiro-Wilk :