Ce billet est un petit retour d'expérience sur l'utilisation de Numpy pour lire
et extraire des données tabulées depuis des fichiers texte.
Chaque section, hormis les objectifs ou la conclusion, correspond soit à une
difficulté rencontrée, une remarque technique, des explications et références
vers la documentation officielle sur un point précis qui m'a fait patauger
quelques temps. Il y a de forte chance, pour certains d'entre vous, que les
points décrits ici vous paraissent évidents, que vous vous disiez "mais qui ne
sait pas ça ?!". J'étais moi-même le premier étonné, depuis que je connais
Numpy, de ne pas savoir ce genre de choses. Je l'étais moins quand autour de
moi, mes camarades ne semblaient pas non plus connaître les petites histoires
numpysiennes que je vais vous conter.
Le Pourquoi et le Où on va au fait ?
J'avais sous la main des fichiers aux données tabulées, type CSV, où les types
de données par colonne étaient clairement identifiés. Je ne souhaitais pas
passer du temps avec le module csv de la bibliothèque standard à convertir
chaque élément en type de base, str, flottants ou dates. Numpy étant déjà
une dépendance du projet, et connaissant la fonction np.genfromtxt, j'ai
évidemment souhaité l'utiliser.
Il était nécessaire de ne lire que certaines colonnes. Je souhaitais associer un
nom à chaque colonne. L'objectif était ensuite d'itérer sur ces données
ligne par ligne et les traiter dans des générateurs Python. Je n'utilise pas ici
Numpy pour faire des opérations mathématiques sur ces tableaux à deux
dimensions avec des types hétérogènes. Et je ne pense d'ailleurs pas qu'il soit
pertinent d'utiliser ce type de tableau pour faire ces opérations.
Attention au tableau Numpy 0D quand le contenu tabulé à parser n'a
qu'une seule ligne (cas d'un np.[rec]array avec plusieurs dtypes).
Impossible d'itérer sur les éléments puisque dimension nulle.
Supposons que vous ayez un fichier tabulé d'une seule ligne :
Name,Play,Age
Coltrane,Saxo,28
J'utilise np.genfromtxt en précisant le type des colonnes que je
souhaite récupérer (je ne prends pas en compte ici la première ligne).
data = np.genfromtxt(filename, delimiter=',',
dtype=[('name', 'S12'), ('play', object), ('age', int)],
skip_header=1)
Voici la représentation de mon array :
array(('Coltrane', 'Saxo', 28),
dtype=[('name', 'S12'), ('play', 'O'), ('age', '<i8')])
Si dans votre code, vous avez eu la bonne idée de parcourir vos données avec :
for name, instrument, age in data:
# ...
vous pourrez obenir un malheureux TypeError: 'numpy.int64' object is not
iterable par exemple. Vous n'avez pas eu de chance, votre tableau Numpy est à
zéro dimension et une shape nulle (i.e. un tuple vide). Effectivement, itérer
sur un objet de dimension nulle n'est pas chose aisée. Ce que je veux, c'est un
tableau à une dimension avec un seul élément (ici un tuple avec mes trois
champs) sur lequel il est possible d'itérer.
Pour cela, faire:
>>> print data
array(('Coltrane', 'Saxo', 28),
dtype=[('name', 'S12'), ('play', 'O'), ('age', '<i8')])array(('babar', 42.), dytpe=[('f0', 'S5'), ('f1', '<f8')])
>>> print data.shape, data.ndim
(), 0
>>> data = data[np.newaxis]
>>> print data.shape, data.ndim
(1,), 1
Pour les chararray, lire help(np.chararray) ou
http://docs.scipy.org/doc/numpy/reference/generated/numpy.chararray.html. En
particulier:
The chararray class exists for backwards compatibility with
Numarray, it is not recommended for new development. Starting from numpy
1.4, if one needs arrays of strings, it is recommended to use arrays of
dtype object_, string_ or unicode_, and use the free functions
in the numpy.char module for fast vectorized string operations.
On fera donc la distinction entre:
# ndarray of str
na = np.array(['babar', 'celeste'], dtype=np.str_)
# chararray
ca = np.chararray(2)
ca[0], ca[1] = 'babar', 'celeste'
Le type de tableau est ici différent : np.ndarray pour le premier et
np.chararray pour le second. Malheureusement pour np.recfromtxt et en
particulier pour np.recarray, si on transpose le label de la colonne en tant
qu'attribut, np.recarray il est transformé en chararray avec le bon type
Numpy --- |S7 dans notre cas --- au lieu de conserver un np.ndarray de
type |S7.
Exemple :
from StringIO import StringIO
rawtxt = 'babar,36\nceleste,12'
a = np.recfromtxt(StringIO(rawtxt), delimiter=',', dtype=[('name', 'S7'), ('age', int)])
print(type(a.name))
Le print rend bien un objet de type chararray. Alors que :
a = np.genfromtxt(StringIO(rawtxt), delimiter=',', dtype=[('name', 'S7'), ('age', int)])
print(type(a['name']))
affiche ndarray. J'aimerais que tout puisse être du même type, peu importe la
fonction utilisée. Au vue de la documentation et de l'aspect déprécié du type
charray, on souhaiterait avoir que du ndarray de type np.str. J'ai par
ailleurs ouvert le ticket Github 3993 qui n'a malheureusement que peu
de succès :-(
Si certains se demandent quoi mettre pour représenter le type "une chaîne de
caractères" dans un tableau numpy, ils ont le choix :
np.array(['coltrane', 'hancock'], dtype=np.str)
np.array(['coltrane', 'hancock'], dtype=np.str_)
np.array(['coltrane', 'hancock'], dtype=np.string_)
np.array(['coltrane', 'hancock'], dtype='S')
np.array(['coltrane', 'hancock'], dtype='S10')
np.array(['coltrane', 'hancock'], dtype='|S10')
Les questions peuvent être multiples : est-ce la même chose ? pourquoi tant de
choses différentes ? Pourquoi tant de haine quand on lit la doc Numpy et que
l'info ne saute pas aux yeux ? À savoir que le tableau construit sera identique
dans chacun des cas. Il existe peut-être même d'autres moyens de construire un
tableau de type identique en lui passant encore un n-ième argument dtype
différent.
- np.str représente le type str de Python. Il est converti
automatiquement en type chaines de caractère Numpy dont la longueur correspond
à la longueur maximale rencontrée.
- np.str_ et np.string_ sont équivalents et représentent le type
"chaîne de caractères" pour Numpy (longueur = longueur max.).
- Les trois autres utilisent la représentation sous forme de chaîne de
caractères du type np.string_.
- S ne précise pas la taille de la chaîne (Numpy prend donc la chaîne
la plus longue)
- S10 précise la taille de la chaîne (données tronquées si la taille
est plus petite que la chaîne la plus longue)
- |S10 est strictement identique au précédent. Il faut savoir qu'il
existe cette notation <f8 ou >f8 pour représenter un
flottant. Les chevrons signifient little endian ou big endian
respectivement. Le | sert à dire "pas pertinent". Source: la section
typestr sur la page
http://docs.scipy.org/doc/numpy/reference/arrays.interface.html
À noter que j'ai particulièrement apprécié l'utilisation d'un symbole pour
spécifier une information non pertinente --- depuis le temps que je me demandais
ce que voulait bien pouvoir dire ce pipe devant le 'S'.
Pandas, bibliothèque Python d'analyse de données basée sur Numpy, propose, via
sa fonction read_csv, le même genre de fonctionnalités. Il a l'avantage de
convertir les types par colonne sans lui donner d'information de type, qu'on
lise toutes les colonnes ou seulement quelques unes. Pour les colonnes de type
"chaîne de caractères", il prend un dtype=object et n'essaie pas de deviner
la longueur maximale pour le type np.str_. Vous ne rencontrerez donc pas "le
coup des chaînes vides/tronquées" comme avec dtype='S'.
Je ne m'étalerai pas sur tout le bien que je pense de cette bibliothèque. Je
vous invite par ailleurs à lire/ parcourir un billet de novembre qui expose un certain nombre de
fonctionnalités croustillantes et accompagné d'un IPython Notebook.
Et pourquoi pas Pandas ? Il ne me semble pas pertinent de dépendre d'une
nouvelle bibliothèque, aussi bien soit-elle, pour une fonction, aussi utile
soit-elle. Pandas est un projet intéressant, mais jeune, qui ne se distribue
pas aussi bien que Numpy pour l'instant. De plus, le projet sur lequel je
travaillais utilisait déjà Numpy. Je n'avais besoin de rien d'autre pour
réaliser mon travail, et dépendre de Pandas ne me semblait pas très
pertinent. Je me suis donc contenté des fonctions np.{gen,rec}fromtxt qui
font très bien le boulot, avec un dtype comme il faut, tout en retenant les
boulettes que j'ai faites.