API de machine learning de classification NLP pour la production avec FastAPI et Transformers

La première version de FastAPI est sortie fin 2018 et ce framework est de plus en plus utilisé en production depuis lors. C'est ce que nous utilisons derrière NLP Cloud. FastAPI est un excellent moyen de servir facilement et de façon performante nos centaines de modèles NLP, pour l'extraction d'entités (NER), la classification de texte, l'analyse de sentiment, la réponse aux questions, la synthèse... Nous avons constaté que FastAPI est un excellent moyen de servir des modèles d'apprentissage profonds basés sur les transformers.

Dans cet article, nous avons pensé qu'il serait intéressant de vous montrer comment nous mettons en place une API NLP basée sur les transformers Hugging Face avec FastAPI.

Pourquoi utiliser FastAPI ?

Avant FastAPI, nous avions essentiellement utilisé Django Rest Framework pour nos API Python, mais nous nous sommes rapidement intéressés par FastAPI pour les raisons suivantes :

Ces performances exceptionnelles rendent FastAPI parfaitement adapté aux API d'apprentissage automatique servant des modèles à base de transformers comme les nôtres.

Installez FastAPI

Afin que FastAPI fonctionne, nous le jumelons avec le serveur Uvicorn ASGI, qui est la façon moderne de gérer nativement les requêtes Python asynchrones avec asyncio. Vous pouvez soit décider d'installer FastAPI avec Uvicorn manuellement ou télécharger une image Docker prête à l'emploi. Montrons d'abord l'installation manuelle :

pip install fastapi[all]

Puis vous pouvez démarrer le service avec :

uvicorn main:app

Sebastián Ramírez, le créateur de FastAPI, fournit plusieurs images Docker prêtes à l'emploi qui rendent FastAPI très facile à utiliser en production. L'image Uvicorn + Gunicorn + FastAPI tire partie de Gunicorn afin d'utiliser plusieurs processus en parallèle. Au final, grâce à Uvicorn, vous pouvez gérer plusieurs instances FastAPI dans le même processus Python, et grâce à Gunicorn vous pouvez générer plusieurs processus Python.

Votre application FastAPI va démarrer automatiquement en démarrant le conteneur avec docker run.

Il est important de bien lire la documentation de ces images Docker car il y a quelques paramètres que vous pourriez vouloir modifier, comme par exemple le nombre de processus parallèles créés par Gunicorn. Par défaut, l'image produit autant de processus que le nombre de cœurs CPU sur votre machine. Mais dans le cas de modèles de machine learning exigeants comme NLP Transformers, cela peut rapidement conduire à des dizaines de Go de mémoire utilisée. Une stratégie est d'utiliser l'option --preload de Gunicorn, afin de charger votre modèle une seule fois en mémoire et de le partager avec tous les processus FastAPI Python. Une autre option serait de plafonner le nombre de processus Gunicorn. Les deux ont des avantages et des inconvénients, mais c'est au-delà du champ d'application de cet article.

API FastAPI + Transformers simple pour la classification de texte

La classification de texte est le fait de déterminer les sujets abordés dans un texte (Space? Business? Food?...). Plus de détails sur la classification ici.

Nous voulons créer un point d'accès API qui effectue la classification de texte en utilisant le modèle Bart Large MNLI de Facebook, qui est un modèle pré-entraîné basé sur les transformers Hugging Face, parfaitement adapté pour la classification de texte.

Notre API prendra un morceau de texte en entrée, ainsi que des catégories potentielles (appelées labels), et elle retournera un score pour chaque catégorie (plus le score est élevé, plus le résultat est probable).

Nous allons envoyer les infos via une requête POST comme ceci :

curl "https://api.nlpcloud.io/v1/bart-large-mnli/classification" \
-H "Authorization: Token e7f6539e5a5d7a16e15" \
-X POST -d '{
    "text":"John Doe is a Go Developer at Google. He has been working there for 10 years and has been awarded employee of the year.",
    "labels":["job", "nature", "space"]
}'

Ce qui retournera quelque chose comme :

{
    "labels": [
        "job",
        "space",
        "nature"
    ],
    "scores": [
        0.9258803129196167,
        0.19384843111038208,
        0.010988432914018631
    ]
}

Voici comment s'y prendre avec FastAPI et les transformers :

from fastapi import FastAPI
from pydantic import BaseModel, constr, conlist
from typing import List
from transformers import pipeline

classifier = pipeline("zero-shot-classification",
                model="facebook/bart-large-mnli")
app = FastAPI()

class UserRequestIn(BaseModel):
    text: constr(min_length=1)
    labels: conlist(str, min_items=1)

class ScoredLabelsOut(BaseModel):
    labels: List[str]
    scores: List[float]

@app.post("/classification", response_model=ScoredLabelsOut)
def read_classification(user_request_in: UserRequestIn):
    return classifier(user_request_in.text, user_request_in.labels)

Tout d'abord, nous chargeons le modèle Bart Large MNLI de Facebook à partir du dépôt Hugging Face, et nous l'initialisons correctement à des fins de classification, grâce à Transformer Pipeline:

classifier = pipeline("zero-shot-classification",
                model="facebook/bart-large-mnli")

Plus tard nous pourrons utiliser le modèle ainsi :

classifier(user_request_in.text, user_request_in.labels)

Deuxième chose importante: nous effectuons la validation des données grâce à Pydantic. Pydantic vous force à déclarer à l'avance le format d'entrée et de sortie de votre API, ce qui est génial du point de vue de la documentation, mais aussi parce qu'il limite les erreurs potentielles. En Go vous feriez à peu près la même chose en faisant du JSON unmarshalling vers des structs. constr(min_length=1) est une façon simple de valider que le champs "text" doit au moins être constitué d'un caractère. Et conlist(str, min_items=1) valide le fait que la liste contenant les labels doit au moins contenir un élément. List[str] signifier que la sortie "labels" doit être une liste de strings et List[float] signifie que les scores retournés doivent être une liste de floats. Si le modèle retourne des valeurs qui ne respectent pas ces formats, cela soulèvera une erreur.

class UserRequestIn(BaseModel):
    text: constr(min_length=1)
    labels: conlist(str, min_items=1)

class ScoredLabelsOut(BaseModel):
    labels: List[str]
    scores: List[float]

Enfin, le décorateur @app.post("/entities", response_model=EntitiesOut) permet de facilement déclarer que l'on n'accepte que les requêtes JSON, pour un point d'accès spécifique.

Validation de données plus avancée

Vous pouvez effectuer des validations plus complexes, comme par exemple de la composition. Par exemple, disons que vous faites de la reconnaissance d'entités nommées (NER), donc votre modèle retourne une liste d'entités. Chaque entité aurait 4 champs : text, type, start et position. Voici comment vous pourriez vous y prendre :

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

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

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

Jusqu'à présent, nous avons laissé Pydantic gérer la validation. Cela fonctionne dans la plupart des cas, mais parfois vous pourriez vouloir soulever dynamiquement une erreur par vous-même en fonction de conditions complexes qui ne sont pas gérées nativement par Pydantic. Par exemple, si vous voulez retourner manuellement une erreur HTTP 400, vous pouvez faire ce qui suit :

from fastapi import HTTPException

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

Bien entendu vous pouvez faire bien plus !

Ajuster le root path

Si vous utilisez FastAPI derrière un reverse proxy, vous aurez probablement besoin de jouer avec le root path.

La chose difficile est que, derrière un reverse proxy, l'application ne connaît pas l'URL intégrale du point d'accès, donc nous devons lui dire explicitement quel est cette URL.

Par exemple ici l'URL entière vers notre point d'accès pourrait ne pas être /classification, mais plutôt /api/v1/classification. Nous ne voulons pas hardcoder cette URL afin que notre code API soit découplée au maximum du reste de l'application. Voici comment faire :

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

Ou bien vous pouvez passer un paramètre à Uvicorn lors du démarrage :

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

Conclusion

J'espère que nous vous avons montré avec succès comment FastAPI peut être la bonne solution pour une API NLP. Pydantic rend le code très expressif et moins sujet aux erreurs.

FastAPI a d'excellentes performances et permet d'utiliser Python asyncio clé en main, ce qui est idéal pour les modèles de machine learning exigeants comme les modèles NLP basés sur Transformer. Nous utilisons FastAPI depuis près d'un an chez NLP Cloud et nous n'avons jamais été déçus jusqu'à présent.

Si vous avez des questions n'hésitez pas, ce sera avec plaisir !

Julien Salinas
CTO at NLP Cloud