Entraînement et intégration d'un classifieur de textes avec spaCy
Customiser une pipelines spaCy avec vos propres composants de NLP
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..
%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 = 3
data = pd.read_csv("spacy_textcat/reddit_data.csv")
data
cats = data.subreddit.unique().tolist()
cats
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)
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 :
- 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 composanttextcat
.
[nlp]
lang = "en"
pipeline = ["textcat"]
batch_size = 1000
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
tokenizer = {"@tokenizers":"spacy.Tokenizer.v1"}
- 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 prefixcomponents.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 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",
},
)
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
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)
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)
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 :
- 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)
À 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 !