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 cet article (accompagné de ce notebook, testable sur binder ou google colab) existe : 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
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 = 6
data = pd.read_csv("spacy_textcat/reddit_data.csv")
data
text | subreddit | |
---|---|---|
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. | datasets |
1 | I'm making an ESG stock analysis program in Java, and so far the only free ESG API I've come across is ESGEnterprise, but I'm having trouble retrieving the data. Has anyone had any success/have any recs for other ESG APIs out there. | datasets |
... | ... | ... |
720 | This _URL_ position is currently open and I wanted to share with you! | LanguageTechnology |
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.
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>
Le workflow recommandé avec SpaCy utilise des fichiers de configuration. Ils permettent de configurer chaque composant de la pipeline, de choisir quels composants entraîner etc.
Nous utiliserons ce fichier de configuration, qui utilise le classifieur de textes par défaut de SpaCy. La configuration peut être 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 : 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 headernlp
): 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"}
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.

Contrairement à ce qui peut être trouvé dans la plupart des ressources 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 telle sorte à bénéficier des fonctionnalité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 internes 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 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 attribut .cats
et que dans ce cas la catégorie datasets
est prédite avec ~85% 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ères de le faire :
- 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
- 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() )
- 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}
À 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 personnalisé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 fonctionnalité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 aura été utile et n'hésitez pas à me contacter si vous voulez plus de détails !