JSON dans les projets data science : Trucs & Astuces
Tags: python, tutorial, french, json
Published: 2024-03-06 23:51:39

Quelques astuces et librairies utiles pour manipuler du json dans vos projets de data science.

## Le module standard `json`

Python possède un module standard nommé `json` qui permet de manipuler rapidement des fichiers JSON

### Chargement

```python
import json

with open("data/example.json", "r") as f:
    data = json.load(f)

data
# [{'id': 0, 'content': [0.0, 0.0, 1.0]}, {'id': 1, 'content': [0.0, 1.0, 0.0]}]
```

### Sauvegarde

**Première astuce** : lorsque l'on travaille avec des données textuelles l'option `ensure_ascii=False` est très utile 
pour préserver, entre autres, les accents lors de la sauvegarde

```python
with open("data/example.json", "w") as f:
    json.dump(data, f, ensure_ascii=False)
```

**Deuxième astuce** : l'option `indent` de la méthode `dump` permet d'indenter les données dans le fichier de sauvegarde

```python
with open("data/example.json", "w") as f:
    json.dump(data, f, ensure_ascii=False, indent=2)
```

## Les problématiques liées à numpy

On utilise très souvent [`numpy`](https://numpy.org/) dans les projets de data science.
Or les objets `numpy` ne sont pas JSON-sérialisable et nécessite donc une conversion en objets standards python pour 
être sauvegardés : 

```python
import numpy as np

data = np.array([[0., 0., 1.], [0., 1., 0.]])

with open("data/numpy.json", "w") as f:
    json.dump(data, f, ensure_ascii=False)

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 6
      3 data = np.array([[0., 0., 1.], [0., 1., 0.]])
      5 with open("data/numpy.json", "w") as f:
----> 6     json.dump(data, f, ensure_ascii=False)

TypeError: Object of type ndarray is not JSON serializable
```

En convertissant le tableau en liste, on parvient à sauvegarder l'objet `data` :

```python
with open("data/numpy.json", "w") as f:
    json.dump(data.tolist(), f, ensure_ascii=False)
```

Mais ce n'est pas très pratique...

Une solution est de créer un [`JSONEncoder`](https://docs.python.org/fr/3/library/json.html#json.JSONEncoder) personnalisé qui convertit les `numpy.ndarray` grâce à leur méthode `tolist` au moment de la sauvegarde : 

```python
class NumpyJSONEncoder(json.JSONEncoder):
    """JSONEncoder to store python dict or list containing numpy arrays"""

    def default(self, obj):
        """Transform numpy arrays into JSON serializable object such as list
        see : https://docs.python.org/3/library/json.html#json.JSONEncoder.default
        """
        if isinstance(obj, np.ndarray):
            return obj.tolist()

        return json.JSONEncoder.default(self, obj)

with open("data/numpy.json", "w") as f:
    json.dump(data, f, ensure_ascii=False, cls=NumpyJSONEncoder)
```

## La librairie `orjson`

[`orjson`](https://github.com/ijl/orjson/tree/master) est la librairie JSON la plus rapide disponible pour python. De
plus elle gère nativement les objets [dataclass](https://docs.python.org/fr/3/library/dataclasses.html), 
[datetime](https://docs.python.org/fr/3/library/datetime.html), [numpy](https://numpy.org/) 
et [UUID](https://docs.python.org/3/library/uuid.html) ce qui est très pratique.

Quelques éléments à retenir lorsque l'on travaille avec `orjson` : 

- Il n'y a pas de méthode `load` ni `dump`, il faut utiliser les méthodes `loads` et `dumps` à la place.
- Il faut utiliser des flags pour utiliser certaines fonctionnalités tels que `orjson.OPT_SERIALIZE_NUMPY` pour 
  sérialiser les objets `numpy`

```python
import orjson

with open("data/example.json", "rb") as f:
    data = orjson.loads(f.read())

with open("data/example.json", "wb") as f:
    f.write(orjson.dumps(data, option=orjson.OPT_SERIALIZE_NUMPY))
```

A noter que l'on écrit dans le fichier json en binaire (d'où `rb` et `wb`).

### Performances

orjson annonce une sérialisation des `numpy.ndarray` 4 à 12 fois plus rapide qu'avec la librairie standard. On peut le
vérifier sur un exemple en comparant les deux méthodes présentées plus haut : 

```python
data = {i: np.random.randn(100) for i in range(100)}

def save_json():
    with open("data/fast.json", "w") as f:
        json.dump(data, f, ensure_ascii=False, cls=NumpyJSONEncoder)

def save_orjson():
    with open("data/orfast.json", "wb") as f:
        f.write(orjson.dumps(data, option=orjson.OPT_SERIALIZE_NUMPY|orjson.OPT_NON_STR_KEYS))

%timeit save_json()
%timeit save_orjson()


# 15.5 ms ± 251 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
# 1.15 ms ± 66.3 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
```

Dans cet exemple orjson est plus de 10 fois plus rapide. A noter que l'option `OPT_NON_STR_KEYS` est utilisé pour 
permettre à orjson de sauvegarder des clés qui ne sont pas des chaines de caractères. 

(Tests exécutés avec python 3.11)

### FastAPI et orjson

La [documentation de `FastAPI` contient un guide pour utiliser `orjson`](https://fastapi.tiangolo.com/advanced/custom-response/?h=orjson#use-orjsonresponse) 
pour sérialiser les réponses JSON.

C'est particulièrement pratique pour les API qui exposent des modèles de machine learning dont les sorties sont souvent
des `numpy.ndarray`

```python
import numpy as np

from fastapi import FastAPI
from fastapi.responses import ORJSONResponse


app = FastAPI()


@app.get("/random-vector", response_class=ORJSONResponse)
async def get_random_vector():
    return ORJSONResponse(np.random.randn(100))
```

## JSON Lines

Il arrive très souvent que l'on manipule des collections d'objets lorsque l'on travaille avec le format JSON.

```json
[
    {"id": 0, "name": "toto"},
    {"id": 1, "name": "titi"},
]
```

Pour être valide, le objets doivent être contenus dans une liste JSON, d'où les crochets encadrant les objets de la
collection. Cependant cela n'est pas du tout pratique pour lire de gros volume de données puisqu'il faut parser 
l'intégraliter du fichier et tout charger en mémoire.

Pour remédier à cela, on peut utilise le format [JSON Lines](https://jsonlines.org/). Il ne s'agit ni plus ni moins que 
de placer un objet JSON par ligne de façon à pouvoir parcourir les objets sans devoir parser l'intégralité de la 
collection d'un seul coup.


```jsonl
{"id": 0, "name": "toto"}
{"id": 1, "name": "titi"}
```

La librairie [`jsonlines`](https://jsonlines.readthedocs.io/en/latest/index.html) est très pratique pour manipuler
de tels fichiers. De plus on peut la combiner avec `orjson`.

```python
import jsonlines
import orjson

with jsonlines.open("data/many_examples.jsonl", "r", loads=orjson.loads) as reader:
    for obj in reader:
        print(obj)

# {'id': 0, 'name': 'toto'}
# {'id': 1, 'name': 'titi'}
```

## TL;DR

Petit résumé des astuces vues ici : 

- Utilier `ensure_ascii=False` lorsqu'on travaille avec la librairie standard `json`
- Considérer la librairie `orjson` pour les performances et les fonctionnalités qu'elle apporte
- Penser au format `JSON Lines` pour les collections d'objets JSON