API de Machine Learning NLP avec FastAPI et spaCy

Temps de lecture ~6 minutes

FastAPI est un nouveau framework d’API pour Python qui est de plus en plus utilisé en production aujourd’hui. Nous utilisons FastAPI derrière NLP Cloud. NLP Cloud est une API basée sur spaCy et les transformers HuggingFace, afin de proposer de l’extraction d’entités (NER), de l’analyse de sentiments, de la classification de texte, du résumé de texte, et bien plus. FastAPI nous a aidé à rapidement construire une API de machine learning robuste et rapide afin de servir les modèles NLP.

Laissez-moi vous expliquer les raisons de ce choix, et vous montrer comment mettre en place FastAPI et spaCy pour de l’extraction d’entités.

Pourquoi FastAPI ?

Jusqu’à récemment, j’avais pour habitude d’utiliser Django Rest Framework pour mes APIs Python. Mais FastAPI propose plusieurs fonctionnalités intéressantes :

  • C’est très rapide
  • C’est bien documenté
  • C’est facile à utiliser
  • Cela génère automatiquement les schémas d’APIs pour vous (OpenAPI par exemple)
  • Cela utilise la validation de types avec Pydantic. Pour un développeur Go tel que moi, qui suis habitué au typage statique, c’est très sympa de pouvoir profiter du typage de cette façon. Cela rend le code plus propre, et moins sujet aux erreurs.

Les performances de FastAPI sont censées en faire un excellent candidat pour les APIs de machine learning. Étant donné que l’on sert un grand nombre de modèles NLP exigeants en termes de performances chez NLP Cloud, FastAPI est une très bonne solution.

Mettre en place FastAPI

La première option dont vous disposez est d’installer FastAPI et Uvicorn (le serveur ASGI en amont de FastAPI) par vous-même :

pip install fastapi[all]

Comme vous le voyez, FastAPI tourne derrière un serveur ASGI, ce qui signifie qu’il peut nativement supporter les requêtes Python asynchrones avec asyncio.

Puis vous n’avez qu’à démarrer votre app avec quelque chose comme ça :

uvicorn main:app

Une autre option est d’utiliser une des images Docker généreusement mises à disposition par Sebastián Ramírez, le créateur de FastAPI. Ces images sont maintenues et sont clé-en-main.

Par exemple, l’image Uvicorn + Gunicorn + FastAPI ajoute Gunicorn à la stack afin de gérer plusieurs processus Python en parallèle.

L’application est censée démarrer automatiquement via un docker run si vous avez correctement suivi la documentation de l’image.

Ces images sont personnalisables. Par exemple, vous pouvez modifier le nombre de processus créés par Gunicorn. Il est important de jouer sur ce type de paramètres en fonction des ressources que demande votre API. Si vous API sert un modèle de machine learning utilisant plusieurs gigas de mémoire, vous feriez bien de diminuer la concurrence par défaut de Gunicorn, sans cela votre application consommera vite trop de mémoire.

API de NER simple avec FastAPI et spaCy

Imaginons que vous souhaitiez créer un point d’accès API qui fasse de l’extraction d’entités (NER) avec spaCy. En gros, le but de la NER est d’extraire des entités telles que le nom, l’entreprise, le poste… à partir d’une phrase. Plus d’infos sur la NER ici si besoin.

Ce point d’accès prendra une phrase en entrée, et retournera une liste d’entités. Chaque entité est composée de la position du premier caractère de l’entité, la position du dernier caractère de l’entité, le type de l’entité, et le texte de l’entité lui-même.

Le point d’accès devra être contacté via des requêtes POST de cette façon :

curl "http://127.0.0.1/entities" \
  -X POST \
  -d '{"text":"John Doe is a Go Developer at Google"}'

Et il retournera quelque chose comme ce qui suit :

[
  {
    "end": 8,
    "start": 0,
    "text": "John Doe",
    "type": "PERSON"
  },
  {
    "end": 25,
    "start": 13,
    "text": "Go Developer",
    "type": "POSITION"
  },
  {
    "end": 35,
    "start": 30,
    "text": "Google",
    "type": "ORG"
  },
]

Voici comment s’y prendre :

import spacy
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List

model = spacy.load("en_core_web_lg")

app = FastAPI()

class UserRequestIn(BaseModel):
    text: str

class EntityOut(BaseModel):
    start: int
    end: int
    type: str
    text: str

class EntitiesOut(BaseModel):
    entities: List[EntityOut]

@app.post("/entities", response_model=EntitiesOut)
def read_entities(user_request_in: UserRequestIn):
    doc = model(user_request_in.text)

    return {
        "entities": [
            {
                "start": ent.start_char,
                "end": ent.end_char,
                "type": ent.label_,
                "text": ent.text,
            } for ent in doc.ents
        ]
    }

Le première chose ici est que nous chargeons le modèle spaCy. Dans notre exemple nous utilisons un modèle spaCy pré-entraîné pour l’anglais de taille “large”. Les modèles “large” prennent plus de mémoire et plus d’espace disque, mais ont une meilleure précision car ils ont été entraînés sur de plus grands jeux de données.

model = spacy.load("en_core_web_lg")

Par la suite nous utilisons ce modèle spaCy pour de la NER en faisant ce qui suit.

doc = model(user_request_in.text)
# [...]
doc.ents

La seconde chose, qui est une super fonctionnalité de FastAPI, est la possibilité de forcer la validation des données via Pydantic. En gros, vous devez déclarer à l’avance quel sera le format des données entrées par vos utilisateurs, et le format de ce que l’API leur retournera. Si vous êtes développeur Go, vous trouverez cela très similaire à l’unmarshalling JSON avec les structs. Par exemple, nous retournons le format d’une entité retournée de cette façon :

class EntityOut(BaseModel):
    start: int
    end: int
    type: str
    text: str

Notez que start et end sont des positions dans la phrase, ce sont donc des entiers, et type et text sont des chaînes de caractères. Si l’API essaie de retourner une entité qui ne répond pas à ce format (par exemple, si start n’est pas un entier), FastAPI va soulever une erreur.

Comme vous pouvez le voir, il est possible d’inclure une classe de validation dans une autre. Ici nous retournons une liste d’entités, nous devons donc déclarer ce qui suit :

class EntitiesOut(BaseModel):
    entities: List[EntityOut]

Certains types simples tels que int et str sont natifs, mais des types plus complexes tels que List doivent être explicitement importés.

Pour être plus concis, la validation de la réponse peut se fait via un décorateur :

@app.post("/entities", response_model=EntitiesOut)

Validation avancée

Vous pouvez effectuer de la validation plus avancée avec FastAPI et Pydantic. Par exemple, si vous souhaitez que l’entrée utilisateur ait une taille minimum de 10 caractères, vous pouvez faire ce qui suit :

from pydantic import BaseModel, constr

class UserRequestIn(BaseModel):
    text: constr(min_length=10)

Maintenant, un autre cas : que se passe-t-il si la validation via Pydantic passe, mais que vous réalisez plus tard qu’il y a un problème avec les données et donc que vous voulez retourner un code HTTP 400 ?

Il vous suffit de soulever une HTTPException :

from fastapi import HTTPException

raise HTTPException(
            status_code=400, detail="Your request is malformed")

Il s’agit juste de quelques exemples, mais vous pouvez faire bien plus ! Pour cela jetez un oeil à la documentation de FastAPI et de Pydantic.

Chemin racine

Il est très commun de faire tourner de telles API derrière un reverse proxy. Par exemple chez NLPCloud.io nous utilisons le reverse proxy Traefik pour cela.

Un point délicat lors que l’on utilise un reverse proxy est que notre sous-application (ici l’API) ne connait pas nécessairement le chemin URL entier. Et c’est en réalité une excellente chose car cela montre que votre API est faiblement couplée au reste de votre application.

Par exemple ici nous souhaitons que notre API croie que l’URL du point d’accès est /entities, mais en réalité la vraie URL peut être quelques chose comme /api/v1/entities. Voici comment s’y prendre en modifiant le chemin racine :

app = FastAPI(root_path="/api/v1")

Vous pouvez aussi arriver au même résultat en passant un paramètre supplémentaire à Uvicorn dans le cas où vous démarrez Uvicorn manuellement :

uvicorn main:app --root-path /api/v1

Conclusion

Comme vous avez pu le voir, créer une API avec FastAPI est simple comme bonjour, et la validation avec Pydantic rend le code très expressif (ce qui permet en retour d’écrire moins de documentation) et moins sujet aux erreurs.

FastAPI possède à la fois de très bonnes performances et la possibilité d’utiliser des requêtes asynchrones de façon native avec asyncio, ce qui est excellent pour les modèles de machine learning exigeants. L’exemple ci-dessus traitant de l’extraction d’entités avec spaCy et FastAPI peut presque être considéré comme prêt pour la production (bien sûr le code de l’API n’est qu’une petite partie dans le cadre d’une vraie application en cluster). Jusqu’à présent, FastAPI n’a jamais été l’élément limitant dans notre infrastructure NLPCloud.io.

Si vous avez des questions, n’hésitez pas !

Also available in English

Reverse Proxy Traefik avec Docker Compose et Docker Swarm

Chez NLPCloud.io nous utilisons Traefik comme reverse proxy au sein de notre cluster Docker Swarm. Traefik s'intègre parfaitement à Docker Compose. Continuer de lire