Introduction

spaCy est une bibliothèque de Traitement Automatique du Langage Naturel (TALN ou NLP en anglais) et un framework pour industrialiser des applications de NLP et de Machine Learning. Cette bibliothèque comprend un grand nombre de fonctionalités classiques pour le traitement du langage naturel (tokenization, analyse syntaxique, POS-tagging, extraction d'entités), personalisables à l'envi et que l'on peut facilement compléter par des coposants personalisés pour des applications plus spécifiques (classification de textes, de token, et autres tâches spécifiques de NLP).

Outre les fonctionalités de NLP et d'industrialisation du NLP, spaCy propose un grand nombre de modèles pré-entraînés qui permettent en quelques ligne de code de bénéficier de l'état de l'art sur des tâches complexes de NLP.

Les ressources pour entraîner un modèle de machine learning sur du texte (classification, extraction d'entités, etc.) avec spaCy ne manquent pas, mais très peu vont jusqu'au bout du chemin: l'intégration avec d'autres composants standards et pré-entraînés de spaCy (ou d'autres fournisseurs), tels qu'un DependencyParser, tagger POS qui ne nécessitent pas de spécialisation ou de fine tuning.

C'est pour combler ce manque que ce notebook existe : vous guider dans le process de configuration d'un composant de classification de textes, de l'entraînement depuis un script python et jusqu'à son intégration dans une pipeline spaCy pré-entraînée afin qu'elle soit réutilisable depuis le reste de vos applications..

Pre-requis

Tout d'abord, installons les packages nécessaires pour ce tutoriel: spacy pour le NLP, pandas pour inspecter nos données, et sklearn qui va faciliter la séparation du jeu de données en splits.

%pip install -q "spacy>3.0.0" pandas sklearn
Note: you may need to restart the kernel to use updated packages.

Nous devons également télécharger un modèle pré-entraîné de spaCy : https://spacy.io/models/en#en_core_web_md. La ligne de commande suivante doit être exécutée depuis le même environnement que votre kernel de notebook.

!python -m spacy download en_core_web_md

Les données & la tâche de classification

Nous travaillerons sur un dataset qui a été extrait à travers l'API de reddit. Le dataset a déjà été préparé et nettoyé pour qu'il puisse être facilement importé et converti en documents spaCy. Vous pourrez le trouver ici.

Le dataset est composé du corps de texte d'une selection de posts provenant de quelques subreddits liés à la data science. L'objectif de notre tâche de machine learning sera de deviner à partir du corps de texte de quel subreddit le post provient. Même si l'intérêt en soi est assez limité, c'est un bon point de départ pour démarrer et il présente l'avantage d'être déjà annoté.

Jettons un oeil à ce qu'il y a dans le dataset.

import pandas as pd

pd.options.display.max_colwidth = None
pd.options.display.max_rows = 3
data = pd.read_csv("spacy_textcat/reddit_data.csv")
data
text tag subreddit id
0 I’m looking for datasets or api source that quantifies fan base, or preferably, bettors’ sentiment regarding a team’s performance or direction. Does anyone know of an API that tracks this? For now I’m looking specifically for NBA, but am also interested in MLB, NFL, and NCAA f-ball and b-ball. API datasets s0vufk
... ... ... ... ...
720 This _URL_ position is currently open and I wanted to share with you! NaN LanguageTechnology s30ccv

721 rows × 4 columns

cats = data.subreddit.unique().tolist()
cats
['datasets', 'dataengineering', 'LanguageTechnology']

Le dataset est composé d'un peu plus de 700 posts et de leurs subreddits associé. Créons maintenant les datasets d'entraînement et de validation en y incluant les annotations.

Création du dataset d'entraînement

Tout d'abord écrivons une fonction permettant de transformer le dataset dans le format binaire pour spacy, que l'on va stocker temporairement en local.

from typing import Set, List, Tuple
from spacy.tokens import DocBin
import spacy

# Load spaCy pretrained model that we downloaded before
nlp = spacy.load("en_core_web_md")

# Create a function to create a spacy dataset
def make_docs(data: List[Tuple[str, str]], target_file: str, cats: Set[str]):
    docs = DocBin()
    # Use nlp.pipe to efficiently process a large number of text inputs, 
    # the as_tuple arguments enables giving a list of tuples as input and 
    # reuse it in the loop, here for the labels
    for doc, label in nlp.pipe(data, as_tuples=True):
        # Encode the labels (assign 1 the subreddit)
        for cat in cats:
            doc.cats[cat] = 1 if cat == label else 0
        docs.add(doc)
    docs.to_disk(target_file)
    return docs

Séparons jeux d'entraînement et de validation.

from sklearn.model_selection import train_test_split

X_train, X_valid, y_train, y_valid = train_test_split(data["text"].values, data["subreddit"].values, test_size=0.3)

make_docs(list(zip(X_train, y_train)), "train.spacy", cats=cats)
make_docs(list(zip(X_valid, y_valid)), "valid.spacy", cats=cats)
<spacy.tokens._serialize.DocBin at 0x12b189100>

Creation et configuration du composant de classification de textes

Le workflow recommandé avec spaCy utilise des fichiers de configuration. Ils permettent de configurer chaque composant de la pipeline, de choisir quels composant entrâiner etc.

Nous utiliserons ce fichier de configuration, qui utilises le classifieur de textes par défaut de spaCy. La configuration peut être re-générée en suivant ce guide : https://spacy.io/usage/training#quickstart et nous l'avons customisé pour qu'il utilise ce model proposé par spaCy également : https://spacy.io/api/architectures#TextCatBOW.

Il y a deux parties qu'il est important de noter dans ce fichier :

  1. La définition de la pipeline (sous le header nlp): La pipeline ne contient que le composant textcat (de classification) puisque c'est celui pour lequel nous avons des données annotées et le seul que nous allons entraîner aujourd'hui. Un autre détail qui a son importance est le tokenizer qui, comme on peut le voir est spécifié à cet endroit, et est ici laissé à la valeur par défaut proposée par spaCy. C'est le seul pré-requis pour notre composant textcat.
[nlp]
lang = "en"
pipeline = ["textcat"]
batch_size = 1000
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
tokenizer = {"@tokenizers":"spacy.Tokenizer.v1"}
  1. La spécification du modèle : Notons le paramètre exclusive_classes qui a été mis à true puisque nos posts ne viennent que d'un seul subreddit. Notons aussi qu'il a fallu rajouter le prefix components.textcat dans les headers à la configuration donnée dans la documentation.
[components.textcat]
factory = "textcat"
scorer = {"@scorers":"spacy.textcat_scorer.v1"}
threshold = 0.5

[components.textcat.model]
@architectures = "spacy.TextCatBOW.v2"
exclusive_classes = true
ngram_size = 1
no_output_layer = false
nO = null
Plus de détails sur les pipelines SpaCy Une pipeline SpaCy est une architecture logicielle hautement modulaire et configurable spécialisée pour le traitement automatique de textes. Comme on peut le voir sur l'illustration plus bas, la pipeline contient une étape obligatoire qui est la tokenisation du document, brique de base utilisée par l'ensemble les algorithmes d'analyse de documents. Ensuite viennent une succession de composants (ou pipes) qui sont éxécutés dans l'ordre spécifié, mais qui ne dépendent pas nécessairement les uns des autres. Cela sera dans le code du composant que ces dépendences vont se définir, par exemple en accédant à des attributs définis dans l'objet document de SpaCy.

Entraînement du composant de classification de textes

Contrairement à ce qui peut être trouvé dans la plupart des tutoriels en ligne ou dans la documentation de SpaCy où l'entraînement est démarré depuis la CLI, nous allons essayer de lancer l'entraînement du composant directement depuis un script python. Cela a l'avantage de pouvoir faire cette étape de manière programmatique, par exemple depuis une pipeline de donnée (avec airflow, dagster, ou équivalent).

Toutefois, nous utiliserons la fonction train pré-définie dans spacy.cli.train de telles sorte à bénéficier des fonctionalités de logging, adaptations et autres vérifications qui sont utilisée dans la CLI. Notons que l'on peut tout à fait, et très facilement partir directement du module spacy.training et de gérer le logging / interaction avec le système de fichier par nous même, ce qui serait recommandé dans du code de production.

from spacy.cli.train import train as spacy_train

config_path = "spacy_textcat/config.cfg"
output_model_path = "output/spacy_textcat"
spacy_train(
    config_path,
    output_path=output_model_path,
    overrides={
        "paths.train": "train.spacy",
        "paths.dev": "valid.spacy",
    },
)
ℹ Saving to output directory: output/spacy_textcat
ℹ Using CPU

=========================== Initializing pipeline ===========================
✔ Initialized pipeline

============================= Training pipeline =============================
ℹ Pipeline: ['textcat']
ℹ Initial learn rate: 0.001
E    #       LOSS TEXTCAT  CATS_SCORE  SCORE 
---  ------  ------------  ----------  ------
  0       0          0.67        3.31    0.03
  0     200         97.52       46.14    0.46
  0     400         59.67       61.38    0.61
  1     600         19.23       73.74    0.74
  1     800         11.17       75.77    0.76
  2    1000          2.12       74.20    0.74
  3    1200          1.19       75.33    0.75
  4    1400          0.71       76.68    0.77
  4    1600          0.35       75.91    0.76
  6    1800          0.28       77.79    0.78
  7    2000          0.21       77.91    0.78
  9    2200          0.18       77.49    0.77
 11    2400          0.06       79.36    0.79
 13    2600          0.05       77.81    0.78
 16    2800          0.03       77.81    0.78
 19    3000          0.03       77.98    0.78
 21    3200          0.03       77.05    0.77
 24    3400          0.06       77.95    0.78
 27    3600          0.02       76.41    0.76
 30    3800          0.03       75.48    0.75
 32    4000          0.02       77.60    0.78
✔ Saved pipeline to output directory
output/spacy_textcat/model-last

Nous avons maintenant un modèle de classification entraîné ! spaCy stocke le modèle dans des dossiers, et en sauve deux versions, le dernier état du modèle pour permettre de reprendre depuis ce checkpoint s'il on veut affiner le modèle, et le meilleur état du modèle observé pendant l'entraînement. Dans le fichier meta.json dans le dossier du modèle, on peut voir les scores interne qui ont été calculés pendant la validation, et l'on peut voir ici des scores de ~.8 en Macro F score et un AUC à .93.

Le modèle ainsi entraîné et stocké peut ensuite être chargé via spaCy de cette manière :

import spacy

trained_nlp = spacy.load("output/spacy_textcat/model-best")

# Let's try it on an example text
text = "Hello\n I'm looking for data about birds in New Zealand.\nThe dataset would contain the birds species, colors, estimated population etc."
# Perform the trained pipeline on this text
doc = trained_nlp(text)
# We can display the predicted categories
doc.cats
{'datasets': 0.8466417193412781,
 'dataengineering': 0.07126601785421371,
 'LanguageTechnology': 0.08209223300218582}

On voit que le document une fois traité par la nouvelle pipeline dispose d'un attributs .cats et que dans ce cas la catégorie datasets est prédite avec 84% de confiance.

Cependant, le reste de la pipeline est vide : pas d'information syntaxique, de dépendance ou d'entités.

print("entities", doc.ents)
try:
    print("sentences", list(doc.sents))
except ValueError as e:
    print("sentences", "error:", e)
entities ()
sentences error: [E030] Sentence boundaries unset. You can add the 'sentencizer' component to the pipeline with: `nlp.add_pipe('sentencizer')`. Alternatively, add the dependency parser or sentence recognizer, or set sentence boundaries by setting `doc[i].is_sent_start`.

Ces informations sont en revanche disponible et dans la pipeline pré-entraînée que nous avions utilisée au début (Mais évidemment, pas les catégories).

doc_from_pretrained = nlp(text)
print("entities", doc_from_pretrained.ents)
print("sentences", list(doc_from_pretrained.sents))
print("classification", doc_from_pretrained.cats)
entities (New Zealand,)
sentences [Hello
 I'm looking for data about birds in New Zealand., 
, The dataset would contain the birds species, colors, estimated population etc.]
classification {}

La question est donc, comment combiner les deux pipelines nativement sans avoir à charger deux modèles séparément et écrire beaucoup de code pour recoller les morceaux ?

Intégration du nouveaux composant dans la pipeline existante

Il y a en fait plusieurs manière de le faire :

  1. Créer un pipe et charger le modèle à partir du système de fichiers, mais cela aurait demandé d'utiliser un processus d'entraînement différent de celui que nous avons utilisé ici.
pipe = nlp.add_pipe("textcat")
pipe.from_disk("path/to/model/files") # Note, requires a different folder structure that what we've generated
  1. Charger la pipeline, sauver le modèle du composant dans un fichier ou sous forme binaire, et le charger à nouveau dans un second temps depuis le disque / le binaire dans un nouveau pipe ajouté à la pipeline pré-entraînée.
trained_nlp.get_pipe("textcat").to_disk("tmp")
nlp.add_pipe("textcat").from_disk("tmp")
# OR
nlp.add_pipe("textcat").from_bytes(
    trained_nlp.get_pipe("textcat").to_bytes()
)
  1. Créer le pipe depuis une pipeline source.
nlp_merged = spacy.load("en_core_web_md")
nlp_merged.add_pipe("textcat", source=trained_nlp)
doc_from_merged = nlp_merged(text)

print("entities", doc_from_merged.ents)
print("sentences", list(doc_from_merged.sents))
print("classification", doc_from_merged.cats)
entities (New Zealand,)
sentences [Hello
 I'm looking for data about birds in New Zealand., 
, The dataset would contain the birds species, colors, estimated population etc.]
classification {'datasets': 0.8466417193412781, 'dataengineering': 0.07126601785421371, 'LanguageTechnology': 0.08209223300218582}
/Users/yco/.pyenv/versions/myreddit/lib/python3.8/site-packages/spacy/language.py:707: UserWarning: [W113] Sourced component 'textcat' may not work as expected: source vectors are not identical to current pipeline vectors.
  warnings.warn(Warnings.W113.format(name=source_name))

À partir de là, il est possible de sauver la pipeline sur le disque et de la réutiliser à volonté, ou encore de l'enrichir avec d'autres composants personalisés (pourquoi pas un second classifier, ou encore un modèle de NER complémentaire de celui présent dans la pipeline) avec l'ensemble des fonctionalités.

Conclusion

Dans ce tutoriel, nous avons vu comment entraîner et intégrer un composant de classification de textes à une pipeline pré-existante, d'une manière complètement programmatique. La grande valeur ajoutée de cette procédure est qu'elle peut être complètement automatisée et permettre d'entraîner / assembler un grand nombre de composants de manière modulaire et automatique (par exemple en CI/CD)

J'espère que le post vous auta été utile et n'hésitez pas à me contacter si vous voulez plus de détails dans les commentaires !