Complément - niveau intermédiaire¶
Création d’une Series¶
Un objet de type Series est un tableau numpy à une dimension avec un index, par conséquent, une Series a une certaine similarité avec un dictionnaire, et peut d’ailleurs être directement construite à partir de ce dictionnaire. Notons que, comme pour un dictionnaire, l’accès ou la modification est en , c’est-à-dire à temps constant indépendamment du nombre d’éléments dans la Series.
# Regardons la construction d'une Series
import numpy as np
import pandas as pd
# à partir d'un itérable
s = pd.Series([x**2 for x in range(10)])
print(s)0 0
1 1
2 4
3 9
4 16
5 25
6 36
7 49
8 64
9 81
dtype: int64
# en contrôlant maintenant le type
s = pd.Series([x**2 for x in range(10)], dtype='int8')
print(s)0 0
1 1
2 4
3 9
4 16
5 25
6 36
7 49
8 64
9 81
dtype: int8
# en définissant un index, par défaut l'index est un rang démarrant à 0
s = pd.Series([x**2 for x in range(10)],
index=[x for x in 'abcdefghij'],
dtype='int8',
)
print(s)a 0
b 1
c 4
d 9
e 16
f 25
g 36
h 49
i 64
j 81
dtype: int8
# et directement à partir d'un dictionnaire,
# les clefs forment l'index
d = {k:v**2 for k, v in zip('abcdefghij', range(10))}
print(d){'a': 0, 'b': 1, 'c': 4, 'd': 9, 'e': 16, 'f': 25, 'g': 36, 'h': 49, 'i': 64, 'j': 81}
s = pd.Series(d, dtype='int8')
print(s)a 0
b 1
c 4
d 9
e 16
f 25
g 36
h 49
i 64
j 81
dtype: int8
Évidemment, l’intérêt d’un index est de pouvoir accéder à un élément par son index, comme nous aurons l’occasion de le revoir :
print(s['f'])25
Index¶
L’index d’une Series est un objet implémenté sous la forme d’un ndarray de numpy, mais qui ne peut contenir que des objets hashables (pour garantir la performance de l’accès).
# pour accéder à l'index d'un objet Series
# attention, index est un attribut, pas une fonction
print(s.index)Index(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], dtype='str')
L’index va également supporter un certain nombre de méthodes qui vont faciliter son utilisation. Pour plus de détails, voyez la documentation de l’objet Index et de ses sous-classes.
L’autre moitié de l’objet Series est accessible via l’attribut values. ATTENTION à nouveau ici, c’est un attribut de l’objet et non pas une méthode, ce qui est très troublant par rapport à l’interface d’un dictionnaire.
# regardons les valeurs de ma Series
# ATTENTION !! values est un attribut, pas une fonction
print(s.values)[ 0 1 4 9 16 25 36 49 64 81]
Mais une Series a également une interface de dictionnaire à laquelle on accède de la manière suivante :
# les clefs correspondent à l'index
k = s.keys() # attention ici c'est un appel de fonction !
print(f"Les clefs: {k}")Les clefs: Index(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], dtype='str')
# et les couples (clefs, valeurs) sous forme d'un objet zip
for k,v in s.items(): # attention ici aussi c'est un appel de fonction !
print(k, v)a 0
b 1
c 4
d 9
e 16
f 25
g 36
h 49
i 64
j 81
# pour finir remarquons que le test d'appartenance est possible sur les index
print(f"Est-ce que a est dans s ? {'a' in s}")
print(f"Est-ce que z est dans s ? {'z' in s}")Est-ce que a est dans s ? True
Est-ce que z est dans s ? False
Vous remarquez ici qu’alors que values et index sont des attributs de la Series, keys() et items() sont des méthodes. Voici un exemple des nombreuses petites incohérences de pandas avec lesquelles il faut vivre.
Pièges à éviter¶
Avant d’aller plus loin, il faut faire attention à la gestion du type des objets contenus dans notre Series (on aura le même problème avec les DataFrame). Alors qu’un ndarray de numpy a un type qui ne change pas, une Series peut implicitement changer le type de ses valeurs lors d’opérations d’affectations.
# créons une Series et regardons le type de ses valeurs
s = pd.Series({k:v**2 for k, v in zip('abcdefghij', range(10))})
print(s.values.dtype)int64
# On a déjà vu que l'on ne pouvait pas modifier lors d'une affectation le
# type d'un ndarray numpy
try:
s.values[2] = 'spam'
except ValueError as e:
print(f"On ne peut pas affecter une str à un ndarray de int64:\n{e}")On ne peut pas affecter une str à un ndarray de int64:
assignment destination is read-only
# Par contre, on peut le faire sur une Series
s['c'] = 'spam'
# et maintenant le type des valeurs de la Series a changé
print(s.values.dtype)---------------------------------------------------------------------------
LossySetitemError Traceback (most recent call last)
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/series.py:1090, in Series.__setitem__(self, key, value)
1089 try:
-> 1090 self._set_with_engine(key, value)
1091 except KeyError:
1092 # We have a scalar (or for MultiIndex or object-dtype, scalar-like)
1093 # key that is not present in self.index.
1094 # GH#12862 adding a new key to the Series
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/series.py:1143, in Series._set_with_engine(self, key, value)
1142 # this is equivalent to self._values[key] = value
-> 1143 self._mgr.setitem_inplace(loc, value)
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/internals/managers.py:2203, in SingleBlockManager.setitem_inplace(self, indexer, value)
2200 if isinstance(arr, np.ndarray):
2201 # Note: checking for ndarray instead of np.dtype means we exclude
2202 # dt64/td64, which do their own validation.
-> 2203 value = np_can_hold_element(arr.dtype, value)
2205 if isinstance(value, np.ndarray) and value.ndim == 1 and len(value) == 1:
2206 # NumPy 1.25 deprecation: https://github.com/numpy/numpy/pull/10615
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/dtypes/cast.py:1750, in np_can_hold_element(dtype, element)
1748 return element
-> 1750 raise LossySetitemError
1752 if dtype.kind == "f":
LossySetitemError:
During handling of the above exception, another exception occurred:
LossySetitemError Traceback (most recent call last)
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/internals/blocks.py:1115, in Block.setitem(self, indexer, value)
1114 try:
-> 1115 casted = np_can_hold_element(values.dtype, value)
1116 except LossySetitemError:
1117 # current dtype cannot store value, coerce to common dtype
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/dtypes/cast.py:1750, in np_can_hold_element(dtype, element)
1748 return element
-> 1750 raise LossySetitemError
1752 if dtype.kind == "f":
LossySetitemError:
During handling of the above exception, another exception occurred:
TypeError Traceback (most recent call last)
Cell In[14], line 2
1 # Par contre, on peut le faire sur une Series
----> 2 s['c'] = 'spam'
4 # et maintenant le type des valeurs de la Series a changé
5 print(s.values.dtype)
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/series.py:1100, in Series.__setitem__(self, key, value)
1097 except (TypeError, ValueError, LossySetitemError):
1098 # The key was OK, but we cannot set the value losslessly
1099 indexer = self.index.get_loc(key)
-> 1100 self._set_values(indexer, value)
1102 except InvalidIndexError as err:
1103 if isinstance(key, tuple) and not isinstance(self.index, MultiIndex):
1104 # cases with MultiIndex don't get here bc they raise KeyError
1105 # e.g. test_basic_getitem_setitem_corner
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/series.py:1168, in Series._set_values(self, key, value)
1165 if isinstance(key, (Index, Series)):
1166 key = key._values
-> 1168 self._mgr = self._mgr.setitem(indexer=key, value=value)
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/internals/managers.py:604, in BaseBlockManager.setitem(self, indexer, value)
600 # No need to split if we either set all columns or on a single block
601 # manager
602 self = self.copy(deep=True)
--> 604 return self.apply("setitem", indexer=indexer, value=value)
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/internals/managers.py:442, in BaseBlockManager.apply(self, f, align_keys, **kwargs)
440 applied = b.apply(f, **kwargs)
441 else:
--> 442 applied = getattr(b, f)(**kwargs)
443 result_blocks = extend_blocks(applied, result_blocks)
445 out = type(self).from_blocks(result_blocks, [ax.view() for ax in self.axes])
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/internals/blocks.py:1118, in Block.setitem(self, indexer, value)
1115 casted = np_can_hold_element(values.dtype, value)
1116 except LossySetitemError:
1117 # current dtype cannot store value, coerce to common dtype
-> 1118 nb = self.coerce_to_target_dtype(value, raise_on_upcast=True)
1119 return nb.setitem(indexer, value)
1120 else:
File /__w/course/course/venv/lib/python3.12/site-packages/pandas/core/internals/blocks.py:468, in Block.coerce_to_target_dtype(self, other, raise_on_upcast)
465 raise_on_upcast = False
467 if raise_on_upcast:
--> 468 raise TypeError(f"Invalid value '{other}' for dtype '{self.values.dtype}'")
469 if self.values.dtype == new_dtype:
470 raise AssertionError(
471 f"Did not expect new dtype {new_dtype} to equal self.dtype "
472 f"{self.values.dtype}. Please report a bug at "
473 "https://github.com/pandas-dev/pandas/issues."
474 )
TypeError: Invalid value 'spam' for dtype 'int64'C’est un point extrêment important puisque toutes les opérations vectorisées vont avoir leur performance impactée et le résultat obtenu peut même être faux. Regardons cela :
s = pd.Series(range(10_000))
print(s.values.dtype)# combien de temps prend le calcul du carré des valeurs
%timeit s**2# ajoutons 'spam' à la fin de la Series
s[10_000] = 'spam'
# oups, je me suis trompé, enlevons cet élément
del s[10_000]
# calculons de nouveau le temps de calcul pour obtenir le carré des valeurs
%timeit s**2# que se passe-t-il, pourquoi le calcul est maintenant plus long
s.values.dtypeMaintenant, les opérations vectorisées le sont sur des objets Python et non plus sur des int64, il y a donc un impact sur la performance.
Et on peut même obtenir un résultat carrément faux. Regardons cela :
# créons une series de trois entiers
s = pd.Series([1, 2, 3])
print(s)# puis ajoutons un nouvel élément, mais ici je me trompe, c'est une str
# au lieu d'un entier
s[3] = '4'
# à part le type qui pourrait attirer mon attention, rien dans l'affichage
# ne distingue les entiers de la str, à part le dtype
print(s)# seulement si j'additionne, les entiers sont additionnés,
# mais les chaînes de caractères concaténées.
print(s+s)Alignement d’index¶
Un intérêt majeur de pandas est de faire de l’alignement d’index sur les objets que l’on manipule. Regardons un exemple :
argent_poche_janvier = pd.Series([30, 35, 20],
index=['alice', 'bob', 'julie'])
argent_poche_février = pd.Series([30, 35, 20],
index=['alice', 'julie', 'sonia'])
argent_poche_janvier + argent_poche_févrierOn voit que les deux Series ont bien été alignées, mais on a un problème. Lorsqu’une valeur n’est pas définie, elle vaut NaN et si on ajoute NaN à une autre valeur, le résultat est NaN. On peut corriger ce problème avec un appel explicite de la fonction add qui accepte un argument fill_value qui sera la valeur par défaut en cas d’absence d’une valeur lors de l’opération.
argent_poche_janvier.add(argent_poche_février, fill_value=0)Accés aux éléments d’une Series¶
Comme les Series sont basées sur des ndarray de numpy, elles supportent les opérations d’accès aux éléments des ndarray, notamment la notion de masque et les broadcasts, tout ça en conservant évidemment les index.
s = pd.Series([30, 35, 20], index=['alice', 'bob', 'julie'])
# qui a plus de 25 ans
print(s[s>25])# regardons uniquement 'alice' et 'julie'
print(s[['alice', 'julie']])# et affectons sur un masque
s[s<=25] = np.nan
print(s)# notons également, que naturellement les opérations de broadcast
# sont supportées
s = s + 10
print(s)Slicing sur les Series¶
L’opération de slicing sur les Series est une source fréquente d’erreur qui peut passer inaperçue pour les raisons suivantes :
on peut slicer sur les labels des index, mais aussi sur la position (l’indice) d’un élément dans la
Series;les opérations de slices sur les positions et les labels se comportent différemment, un slice sur les positions exclut la borne de droite (comme tous les slices en Python), mais un slice sur un label inclut la borne de droite ;
il peut y avoir ambiguïté entre un label et la position d’un élément lorsque le label est un entier.
Nous allons détailler chacun de ces cas, mais sachez qu’il existe une solution qui évite toute ambiguïté, c’est d’utiliser les interfaces loc et iloc que nous verrons un peu plus loin.
Regardons maintenant ces différents problèmes :
s = pd.Series([30, 35, 20, 28], index=['alice', 'bob', 'julie', 'sonia'])
print(s)# on peut accéder directement à la valeur correspondant à alice
print(s['alice'])
# mais aussi par la position d'alice dans l'index
print(s[0])# On peut faire un slice sur les labels, dans ce cas la borne
# de droite est incluse
s['alice':'julie']# et on peut faire un slice sur les positions, mais dans ce cas
# la borne de droite est exclue, comme un slice normal en Python
s[0:2]Ce comportement mérite quelques explications. On voit bien qu’exclure la borne de droite peut se comprendre sur une position (si on exclut i on prend i-1), par contre, c’est mal défini pour un label.
En effet, l’ordre d’un index est défini au moment de sa création et le label venant juste avant un autre label L ne peut pas être trouvé uniquement avec la connaissance de L.
C’est pour cette raison que les concepteurs de pandas ont préféré inclure la borne de droite.
Regardons maintenant plus en détail cette notion d’ordre sur les index.
# Regardons le slice sur un index avec un ordre particulier
s = pd.Series([30, 35, 20, 28], index=['alice', 'bob', 'julie', 'sonia'])
print(s['alice':'julie'])# Si on change l'ordre de l'index, ça change la signification du slice
s = pd.Series([30, 35, 20, 28], index=['alice', 'bob', 'sonia', 'julie'])
print(s['alice':'julie'])Vous devez peut-être vous demander si un slice sur l’index est toujours défini. La réponse est non ! Pour qu’un slice soit défini sur un index, il faut que l’index ait une croissance monotone ou qu’il n’y ait pas de label dans l’index qui soit dupliqué.
Donc la croissance monotonique n’est pas nécessaire tant qu’il n’y a pas de duplication de labels. Regardons cela.
# mon index a des labels dupliqués, mais a une croissance monotonique
s = pd.Series([30, 35, 20, 12], index=['a', 'a', 'b', 'c'])
# le slice est défini
s['a': 'b']# mon index a des labels dupliqués et n'a pas de croissance monotonique
s = pd.Series([30, 35, 20, 12], index=['a', 'b', 'c', 'a'])
# le slice n'est plus défini
try:
s['a': 'b']
except KeyError as e:
print(f"Je n'arrive pas à extraire un slice :\n{e}")Pour finir sur les problèmes que l’on peut rencontrer avec les slices, que se passe-t-il si on a un index qui a pour label des entiers ?
Lorsque l’on va faire un slice, il va y avoir ambiguïté entre la position du label et le label lui-même. Dans ce cas, pandas donne la priorité à la position, mais ce qui est troublant, c’est que lorsqu’on accède à un seul élément en dehors d’un slice, pandas donne la priorité à l’index.
Encore une petite incohérence :
s = pd.Series(['a', 'b', 'c'], index=[2, 0, 1])
print(f"Si on accède directement à un élément, priorité au label : {s[0]}")
print(f"Si on calcule un slice, priorité à la position : {s[0:1]}")loc et iloc¶
La solution à tous ces problèmes est de dire explicitement ce que l’on veut faire. On peut en effet dire explicitement si l’on veut utiliser les labels ou les positions, c’est ce qu’on vous recommande de faire pour éviter les comportements implicites.
Pour utiliser les labels il faut utiliser s.loc[] et pour utiliser les positions if faut utiliser s.iloc[] (le i est pour localisation implicite, c’est-à-dire la position). Regardons cela :
# prenons un cas plus usuel, où les labels sont plutôt des chaines
# notez que la logique est la même quel que soit le type de l'index
s = pd.Series([1000, 2000, 3000, 4000], index=['deux', 'zero', 'un', 'quatre'])
print(s)# accès au label
print(s.loc['zero'])# accès à la position
print(s.iloc[0])# slice sur les labels, ATTENTION, il inclut la borne de droite
print(s.loc['deux':'zero'])# slice sur les positions, ATTENTION, il exclut la borne de droite
print(s.iloc[1:3])Pour allez plus loin, vous pouvez lire la documentation officielle :
http://
Conclusion¶
Nous avons vu que les Series forment une extension des ndarray de dimension 1, en leur ajoutant un index qui permet une plus grande expressivité pour accéder aux éléments. Seulement cette expressivité vient au prix de quelques subtilités (conversions implicites de type, accès aux labels ou aux positions) qu’il faut maîtriser.
Nous verrons dans le prochain complément la notion de DataFrame qui est sans doute la plus utile et la plus puissante structure de données de pandas. Tous les pièges que nous avons vus pour les Series sont valables pour les DataFrames.