Blog

Bist-Parser : une implémentation de bout en bout d'un analyseur de dépendances

Date: 2019-06-14

Cet article est le deuxième et dernier article sur le Dependency Parsing. Nous vous donnerons quelques conseils simples pour la mise en œuvre et les outils pour vous aider à l'améliorer.

Vocabulaire

Un TreeBank est un corpus de texte analysé qui annote la structure syntaxique ou sémantique des phrases. Les arborescences de dépendances sont créées selon différentes approches : soit grâce à des annotateurs humains directement, soit en utilisant des analyseurs automatiques pour fournir une première analyse, qui est ensuite vérifiée par les annotateurs. Une approche commune consiste à utiliser un processus déterministe pour traduire les TreeBanks existants dans un nouveau langage par le biais de règles de tête. La production d'une banque d'arbres de haute qualité est à la fois longue et coûteuse.

CoNLL-U - Computational Natural Language Learning-Universal est une version révisée du format CoNLL-X. Les phrases des TreeBanks sont séparées en deux parties. Les phrases des TreeBanks sont séparées, et chaque mot ou signe de ponctuation est disposé sur une ligne distincte. Chacun des éléments suivants suit le mot, séparé par des tabulations :

  • ID : index du mot dans la phrase, commençant à 1
  • FORM : forme du mot ou ponctuation
  • LEMMA : Lemme ou racine de la forme du mot
  • UPOS : Marqueur universel de la partie du discours
  • XPOS : Étiquette de partie du discours spécifique à une langue ; ne sera pas utilisée dans notre modèle.
  • FEATS : Liste non ordonnée de caractéristiques morphologiques, définies par les dépendances universelles ; indique le genre et le nombre d'un nom, le temps d'un verbe, etc.
  • HEAD : Tête du mot, indique l'index du mot auquel le mot actuel est lié.
  • DEPREL : Relation universelle de dépendance ; indique la relation entre deux mots (sujet ou objet d'un verbe, déterminant d'un substantif, etc.)
  • DEPS : Balises de parties du discours spécifiques à la langue ; ne seront pas utilisées dans notre modèle.
  • MISC : Commentaire ou autre annotation.

https://cdn-images-1.medium.com/max/2000/1*rnZGRNrOZBGfodnnA-E1VQ.png

Un exemple de format CoNLL-U

  • Une entrée est un mot, ou un signe de ponctuation dans une phrase. Elle possède plusieurs attributs, définis ci-dessus. Une phrase est typiquement une concaténation d'entrées (un mot lui-même est un attribut d'une entrée : sa forme), séparées par un espace.

L'implémentation

L'implémentation du Bist-Parser provient de [les auteurs de son article] (https://github.com/elikip/bist-parser). Une mise à jour a été publiée sur GitHub par Xiezhq Hermann. Vous pouvez la trouver ici. Il fonctionne sur Python 3.x, avec torch 0.3.1 (avec ou sans Cuda). Il est très complet et peut être utilisé tel quel. Cependant, afin d'adapter le code à vos données ou de le mettre à jour, vous devez passer par chaque module, ce qui peut être une tâche difficile. Cette partie de l'article vous guidera à travers tous les fichiers et processus.

Universal Dependencies (UD) est un cadre communautaire ouvert pour l'annotation grammaticale. Il fournit des corpus et des outils qui aident grandement à développer un analyseur de dépendances.

Depuis UD, vous pouvez télécharger un corpus de phrases de votre choix (dans n'importe quelle langue disponible, même l'ancien français !), les utiliser telles quelles, et commencer à entraîner votre analyseur de dépendances avec ce type de commande :

python src/parser.py --outdir [répertoire des résultats] --train training.conll --dev development.conll --epochs 30 --lstmdims 125 --lstmlayers 2 [--extrn extrn.vectors]

Vous pouvez détailler ici les hyperparamètres, capturés par le modèle grâce au fichier parser.py.

Comme vous le savez peut-être, lorsque vous entraînez un modèle sur un corpus, le modèle est biaisé vers ce corpus. Vous pouvez entraîner votre modèle sur plusieurs corpus afin de le généraliser davantage. Plusieurs techniques vous permettent d'augmenter les scores, avec TreeBank Embedding comme exemple. Ici, nous avons simplement concaténé des TreeBanks, sans autre traitement.

utils

  • Créer une classe ConllEntry : chaque entrée possède des attributs bien connus : id, forme, lemme, balise PoS universelle, balise PoS spécifique à la langue, caractéristiques morphologiques, tête du mot courant, relation de dépendance, relation de dépendance améliorée et commentaire. Ces attributs sont définis à partir du format Universal Dependencies CoNLL-U. Ce format est utile au modèle pour comprendre ce que sont ses entrées et ce qu'il doit prédire.
  • Lire un fichier CoNLL-U et transformer chaque phrase en une ConllEntry.
  • Compter le vocabulaire : Cette fonction crée un Counter d'attributs ConllEntry et vous permet de savoir comment ces attributs sont distribués dans votre ensemble de données. Si vous voulez déterminer les mots ou les relations les plus fréquents dans votre ensemble de données, cette fonction peut être utile.

mstlstm

Ce fichier contient votre modèle. Tous vos hyper-paramètres et la plupart de vos travaux de suivi se déroulent dans ce fichier.

La méthode forward itère à travers chaque entrée de la phrase. Elle calcule d'abord les vecteurs pour chaque attribut de l'entrée. Avec notre modèle, nous obtenons plusieurs vecteurs qui décrivent le mot, l'étiquette PoS et les exploits. Ces vecteurs sont ensuite concaténés pour former un vecteur de plus grande dimension pour chaque entrée. Ces entrées sont ensuite concaténées ensemble pour former le vecteur de la phrase.

def forward(self, sentence, errs, lerrs):
        for entry in sentence:
            c = float(self.wordsCount.get(entry.norm, 0))
            # dropFlag = (random.random() < (c / (0.33 + c)))
            dropFlag = (random.random() < (c / (0.25 + c)))
            wordvec = self.wlookup(scalar(
                int(self.vocab.get(entry.norm, 0)) if dropFlag else 0)) if self.wdims > 0 else None
            ontovec = self.olookup(scalar(int(self.onto[entry.onto]) if random.random(
            ) < 0.9 else 0)) if self.odims > 0 else None
            cposvec = self.clookup(scalar(int(self.cpos[entry.cpos]) if random.random(
            ) < 0.9 else 0)) if self.cdims > 0 else None
            posvec = self.plookup(
                scalar(int(self.pos[entry.pos]))) if self.pdims > 0 else None
            # posvec = self.plookup(
            #     scalar(0 if dropFlag and random.random() < 0.1 else int(self.pos[entry.pos]))) if self.pdims > 0 else None
            evec = None
            if self.external_embedding is not None:
                evec = self.elookup(scalar(self.extrnd.get(entry.form, self.extrnd.get(entry.norm, 0)) if (
                    dropFlag or (random.random() < 0.5)) else 0))
            entry.vec = cat([wordvec, posvec, ontovec, cposvec, evec])
            entry.lstms = [entry.vec, entry.vec]
            entry.headfov = None
            entry.modfov = None
            entry.rheadfov = None
            entry.rmodfov = None
        num_vec = len(sentence)
        vec_for = torch.cat(
            [entry.vec for entry in sentence]).view(num_vec, 1, -1)
        vec_back = torch.cat(
            [entry.vec for entry in reversed(sentence)]).view(num_vec, 1, -1)
        res_for_1, self.hid_for_1 = self.lstm_for_1(vec_for, self.hid_for_1)
        res_back_1, self.hid_back_1 = self.lstm_back_1(
            vec_back, self.hid_back_1)
        vec_cat = [cat([res_for_1[i], res_back_1[num_vec - i - 1]])
                   for i in range(num_vec)]
        vec_for_2 = torch.cat(vec_cat).view(num_vec, 1, -1)
        vec_back_2 = torch.cat(list(reversed(vec_cat))).view(num_vec, 1, -1)
        res_for_2, self.hid_for_2 = self.lstm_for_2(vec_for_2, self.hid_for_2)
        res_back_2, self.hid_back_2 = self.lstm_back_2(
            vec_back_2, self.hid_back_2)
        for i in range(num_vec):
            sentence[i].lstms[0] = res_for_2[i]
            sentence[i].lstms[1] = res_back_2[num_vec - i - 1]
        scores, exprs = self.__evaluate(sentence, True)
        gold = [entry.parent_id for entry in sentence]
        heads = decoder.parse_proj(scores, gold)
        for modifier, head in enumerate(gold[1:]):
            rscores, rexprs = self.__evaluateLabel(
                sentence, head, modifier + 1)
            goldLabelInd = self.rels[sentence[modifier + 1].relation]
            wrongLabelInd = \
                max(((l, scr) for l, scr in enumerate(rscores)
                     if l != goldLabelInd), key=itemgetter(1))[0]
            if rscores[goldLabelInd] < rscores[wrongLabelInd] + 1:
                lerrs += [rexprs[wrongLabelInd] - rexprs[goldLabelInd]]
        e = sum([1 for h, g in zip(heads[1:], gold[1:]) if h != g])
        if e > 0:
            errs += [(exprs[h][i] - exprs[g][i])[0]
                     for i, (h, g) in enumerate(zip(heads, gold)) if h != g]
        return e

Tout d'abord, il convertit les entrées en vecteurs. Ici, les principaux attributs sont l'incorporation des mots, des lemmes (onto) et des balises PoS (pos). Cependant, nous vous conseillons d'ajouter autant de caractéristiques que possible. Par exemple, vous pouvez avoir accès à des caractéristiques de mots qui indiquent si le nom est singulier ou pluriel, son genre, ou son temps... L'intégration de ces caractéristiques permet à votre BiLSTM de trouver beaucoup plus de motifs.

https://cdn-images-1.medium.com/max/2000/1*4xFlshNFRuuCaL4jF5NmJw.gif

Évolution de l'incorporation de PoS sur deux dimensions

Ensuite, elle alimente le BiLSTM avec ces vecteurs (for = forward, back = backward). La ligne 52 évalue les scores de la phrase. C'est la partie où le Digraphe Pondéré complet est créé. À la ligne 57, le score de la relation est évalué. C'est une astuce intéressante dans ce modèle : plutôt que d'évaluer toutes les possibilités en même temps (|possibilities|=|arcs|.|labels|, ce qui est beaucoup trop élevé), il prédit d'abord les dépendances, puis les relations.

Nous verrons plus tard ce qu'il en est de errs, lerrs et e.

Dans les illustrations ci-dessous, vous pouvez voir l'évolution de l'évaluation des dépendances à travers les lots. Une cellule bleu foncé correspond à un arc pondéré. L'exemple provient d'une phrase typiquement française, "Les commotions cérébrales sont devenues si courantes dans ce sport qu'on les considère presque comme la routine." Vous pouvez repérer des fautes d'orthographe dans la phrase ; ce n'est pas rare dans les banques d'arbres.

https://cdn-images-1.medium.com/max/2874/1*0iYtbAz9sNuTAgTJ2hoFEA.png

Les commotions cérébrales sont devenues si courantes dans ce sport qu'on les considère presque comme la routine.

Les points remarquables de cette phrase sont le mot "devenu", qui est la racine (c'est-à-dire le mot principal), et les trois propositions clairement définies séparées par les mots "que" et "comme".

https://cdn-images-1.medium.com/max/4000/1*GmLrGQ3EtwP3uoHTO6P0QQ.gif

Évolution des scores de dépendance pour la phrase "root Les commotions cérébrales sont devenues si courantes dans ce sport qu'on les considère presque comme la routine. "

https://cdn-images-1.medium.com/max/4000/1*mk2jbr6mWjYZE_AelbRv-g.png

Scores figure (1) - Initialisation

https://cdn-images-1.medium.com/max/4000/1*fvkFem3fw_P3KuU0avS8UQ.png

Scores chiffre (2) - 200 phrases

https://cdn-images-1.medium.com/max/4000/1*SpqFVplIxk1lJNL0HbpN2A.png

Scores figure (3) - 1000 phrases

https://cdn-images-1.medium.com/max/4000/1*m7hx_vy0Aq6GMApu6udCAw.png

Score figure (4) - 6000 phrases

Dans les illustrations ci-dessus, nous pouvons mieux comprendre l'évolution de notre réseau neuronal. Chaque colonne correspond à un token en tant que tête, chaque ligne correspond à un token en tant que dépendant, et chaque cellule correspond au score (ou poids) de l'arc de la tête au dépendant.

Nous reconnaissons l'initialisation aléatoire dans la figure 1, où tous les scores sont autour de zéro, et nous ne pouvons voir aucune forme dans la matrice.

Dans la deuxième image, nous pouvons voir que le vecteur PoS=Déterminant a été pris en compte, et nous pouvons distinguer une forme dans leurs colonnes. Les changements le long des lignes sont moins visibles pour le moment.

Dans la troisième illustration, nous pouvons clairement repérer les deux propositions, séparées par "que". Les arcs sont bien définis avant celui-ci, et un peu moins après. Le signe de ponctuation est bien lié à la racine " devenu. ".

Dans le dernier, on a une idée claire des arcs de principe, le modèle gagne en confiance, et les valeurs de score sont plus étalées.

Une fois que les dépendances sont prédites, le modèle prédit le type de relation. Voici des graphiques des scores donnés pour chaque relation, concernant la relation prédite, étant donné la dépendance correcte.

https://cdn-images-1.medium.com/max/4000/1*9BtuzqLazLkxI1z16svPYA.png

Chiffre de score de relation (1) - Initialisation

.

https://cdn-images-1.medium.com/max/4000/1*zRI-5Dozr8Pkd8HZP6b45A.png

Note de relation (2) - 200 phrases

https://cdn-images-1.medium.com/max/4000/1*JTzKH4spmbK5irp-VXZwSQ.png

Score de relation (3) - 1000 phrases

.

https://cdn-images-1.medium.com/max/4000/1*1mMRoMt0zGRocUFZw9u72w.png

Score de relation figure (4) - 6000 phrases

Le type de relation prédit est en jaune et le type incorrect le plus élevé est en rouge.

Après un certain entraînement, la prédiction devient de plus en plus fiable.

      def train(self, conll_path):
        print('pytorch version:', torch.__version__)
        batch = 1
        eloss = 0.0
        mloss = 0.0
        eerrors = 0
        etotal = 0
        iSentence = 0
        start = time.time()
        with open(conll_path, 'r') as conllFP:
            shuffledData = list(read_conll(conllFP))
            random.shuffle(shuffledData)
            errs = []
            lerrs = []
            for iSentence, sentence in enumerate(shuffledData):
                self.model.hid_for_1, self.model.hid_back_1, self.model.hid_for_2, self.model.hid_back_2 = [
                    self.model.init_hidden(self.model.ldims) for _ in range(4)]
                if iSentence % 100 == 0 and iSentence != 0:
                    print('Processing sentence number:', iSentence,
                          'Loss:', eloss / etotal,
                          'Errors:', (float(eerrors)) / etotal,
                          'Time', time.time() - start)
                    start = time.time()
                    eerrors = 0
                    eloss = 0.0
                    etotal = 0

                conll_sentence = [entry for entry in sentence if isinstance(
                    entry, utils.ConllEntry)]
                e = self.model.forward(conll_sentence, errs, lerrs)
                eerrors += e
                eloss += e
                mloss += e
                etotal += len(sentence)
                if iSentence % batch == 0 or len(errs) > 0 or len(lerrs) > 0:
                    if len(errs) > 0 or len(lerrs) > 0:
                        eerrs = torch.sum(cat(errs + lerrs))
                        eerrs.backward()
                        self.trainer.step()
                        errs = []
                        lerrs = []
                self.trainer.zero_grad()
        if len(errs) > 0:
            eerrs = (torch.sum(errs + lerrs))
            eerrs.backward()
            self.trainer.step()
        self.trainer.zero_grad()
        print("Loss: ", mloss / iSentence)

La méthode principale du modèle est train. Elle mélange d'abord les phrases pour entraîner le modèle dans un ordre différent à chaque fois. Ensuite, la méthode forward est appelée pour chaque phrase. Elle met à jour les poids, renvoie un score e et met à jour deux listes errs et lerrs. Rappelons que e compte chaque token pour lequel la tête prédite est différente de la tête dorée (la référence, du corpus). errs calcule la perte sur la prédiction d'arc, et lerrs calcule la perte sur la prédiction d'étiquette. errs et lerrs sont ensuite additionnés pour produire la perte par rétropropagation : eerrs.

Améliorations et quelques résultats

Pour rendre le modèle encore plus efficace, vous devriez essayer d'ajouter le mot dropout comme hyperparamètre, et essayer différentes valeurs. Nous avons essayé 0, 0.25, 0.33, 0.5, 0.9 et 1 comme valeurs, et la meilleure était 0.9. Cela oblige le modèle à apprendre beaucoup plus des autres vecteurs que des mots.

Vous pourriez également séparer chaque caractéristique et utiliser l'intégration sur chacune d'elles. Cela permettrait une plus grande flexibilité et une meilleure compréhension du modèle.

Avec une telle implémentation, nous avons obtenu un score **LAS de 87.70 **sur la banque de données française Sequoia (Universal Dependencies), sans entraînement spécifique sur ce corpus (après traduction des balises, puisque nos balises sont différentes de celles de UD).

Une fois que votre analyseur de dépendances est entraîné, vous pouvez utiliser les dépendances et les relations pour obtenir une meilleure compréhension de vos données. Même si un modèle NLU est capable de trouver de nombreux modèles, l'ajout de ces entrées peut constituer un gain réel que vous devriez considérer.

Les sources de cet article sont les suivantes :

Côme Cothenet

Lecture : 10 minutes

Prêt à extraire l'or dans vos données ?

Vous souhaitez en savoir plus sur le NLP ? Envoyez-nous un message ou inscrivez-vous gratuitement sur la plateforme Lettria pour vous lancer.

Lancez-vous

S'inscrire à notre newsletter

Recevez tous les mois les actualités de Lettria.