Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Licence CC BY-NC-ND Thierry Parmentelat & Arnaud Legout Inria - UCA

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 O(1)O(1), 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.dtype

Maintenant, 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évrier

On 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 :

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://pandas.pydata.org/pandas-docs/stable/indexing.html

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.