Der syntaktische Parser für das Mittelhochdeutsche wurde mit neuronalen Netzen und bidirektionalen LSTMs auf der Basis von Python 3 und PyTorch implementiert.

Schritte

Vorverarbeitung der Daten

Zum Training des syntaktischen Parsers wird ein schon syntaktisch annotiertes Set an Trainings- und Developmentdaten benötigt. Die Trainingsdaten machen beim maschinellen Lernen üblicherweise ca. 80-90% der vollen Daten aus.

Datenbasis:

  • Die Baumbank Mittelhochdeutsch stellt derzeit die einzige umfangreiche syntaktisch annotierte Baumbank für das Mittelhochdeutsche dar.
  • Fast alle Texte des Referenzkorpus Mittelhochdeutsch vollautomatisch annotiert von Chiarcos et al. (2018) mit dem QuantQualParser
  • TSV-basiertes CoNLL-RDF

Prozess:

  1. CoNLL => TSV
  2. Korrigieren der Developmentdaten: : M001-M102
  3. Extraktion der syntaktischen Annotation
  4. Umwandlung zu TXT-Format und Bereinigung (Sätze zeilenweise, Sonderzeichen, Leerzeichen)

Vorverarbeitung: Text M015-N1_12-1_P als TSV | Text M015-N1_12-1_P bereinigt als TXT

  • Daten als TSV Daten als TXT

Einlesen der Daten

Zur Verarbeitung in einem neuronalen Netzwerk sollen die mittelhochdeutschen Texte in zwei Listen satzweise gespeichert werden:

  1. Beispielteilsatz: (S(Cl(PreF(NX man))(LB(VX gienc))(PPX after(NX wege))))
  2. alle Wörter: ['man', 'gienc', 'after', 'wege']
  3. alle Konstituenten mit Start- und Endpositionen: [('S Cl', 0, 4), ('PreF NX', 0, 1), ('LB VX', 1, 2), ('PPX', 2, 4), ('NX', 3, 4)]

Damit sie als Tensor eingelesen werden können, wird jedes Wort mit buchstabenbasierten IDs ersetzt und es werden Suffixe und Präfixe jedes Wortes mit einer festen Länge von 10 Buchstaben abgespeichert. Kürzere Wörter werden mit Ersatz-IDs aufgefüllt. Dies hat sich schon im RNNTagger (Schmid 2017) als sinnvoll erwiesen.

Konvertierung der Wortlisten in einen numerischen Vektor:

    def words2charIDvec(self, words):
      """ converts a sequence of words to a suffix letter matrix
          and a prefix letter matrix """
        fwd_charID_seqs = []
        bwd_charID_seqs = []
        for word in words:
            fwd_charIDs, bwd_charIDs = self._get_charIDs(word)
            fwd_charID_seqs.append(fwd_charIDs)
            bwd_charID_seqs.append(bwd_charIDs)

        fwd_charID_seqs = numpy.asarray(fwd_charID_seqs, dtype='int32')
        bwd_charID_seqs = numpy.asarray(bwd_charID_seqs, dtype='int32')

        return fwd_charID_seqs, bwd_charID_seqs 

Neuronales Netzwerk aufbauen

Das neuronale Netzwerk berechnet die Bewertungen jeder syntaktischen Kategorie eines Spans und verarbeitet die Wortrepräsentationen, sowie aus den Wortrepräsentationen die Spanrepräsentationen. Zur Berechnung der Spanrepräsentationen werden die Differenzvektoren der Vorwärts- und Rückwärtsrepräsentation der jeweiligen Start- und Endpositionen eines Spans nach dem Vorbild von Gaddy et al. (2018) konkateniert. Die Wort- und die Spanrepräsentationen werden jeweils mit einem bidirektionalen LSTM verarbeitet, sodass der Kontext eines Spans berücksichtigt werden kann. Nach und vor jedem LSTM wurde Dropout von 0.5 als Regularisierung angewandt.

Berechnung der Spanrepräsentation:

      # run the BiLSTM over words
      word_reprs = self.word_rnn(word_reprs.unsqueeze(0)).squeeze(0)
      
      # Forward and backward sequence of word representations
      forward = word_reprs
      backward = word_reprs.flip(1)

      # run the biLSTM over word_representations
      fwd_outputs, _ = self.fwd_rnn(forward)
      bwd_outputs, _ = self.bwd_rnn(backward)
    
      # Forward and backward end and start positions
      startposition_fwd = torch.split(fwd_outputs,int(self.char_rec_size/2),dim=1)[0]
      startposition_bwd = torch.split(bwd_outputs,int(self.char_rec_size/2),dim=1)[0]
      endposition_fwd = torch.split(fwd_outputs,int(self.char_rec_size/2),dim=1)[1]
      endposition_bwd = torch.split(bwd_outputs,int(self.char_rec_size/2),dim=1)[1] 
    
      # concatenate the forward and backward final states to form
      # span representations
      forward = (endposition_fwd-startposition_fwd)
      backward = (startposition_bwd - endposition_bwd)

      # concatenate to build span representation
      span_reprs = torch.cat((forward, backward),-1)
 
      return span_reprs

Ausgabe der Bewertung über Pytorch-Linear-Funktion und ReLU

def forward(self, fwd_charIDs, bwd_charIDs, num_labels):

      # compute the span representations
      span_reprs = self.span_representations(fwd_charIDs, bwd_charIDs)

      # apply dropout
      span_reprs = self.dropout(span_reprs)

      # output (Linear and ReLU)
      self.linear = nn.Linear(self.word_rec_size, num_labels)
      self.output_layer = nn.ReLU()
      
      # apply the output layers
      scores = self.output_layer(self.linear(span_reprs.unsqueeze(0))).squeeze(0)

      return scores

Aufbau des neuronalen Netzes: Visualisierung des Netzwerks

Training

Die neuronalen Netze werden über das Trainingsset (M104-M544) und das Developmentset (M001-M102) trainiert. Dabei werden über die Cross-Entropy-Loss-Funktion die besten Bewertungen immer wieder abgespeichert und anhand der Developmentdaten überprüft. Dazu wird über jeden einzelnen Span der eingegebenen Daten iteriert und dieser in das neuronale Netzwerk eingegeben.

Zur Berechnung des Verlusts wird für jeden Span ein Vektor mit den LabelIDs erstellt. Dieser Vorgang wird über 50 Batches wiederholt und ist aufgrund der hohen Laufzeit auf 250 Sätze beschränkt worden.

Training jedes Spans:

for iteration, (words, labels) in enumerate(sentences):
      for span_no in range(len(labels)):
          for label in labels:
              start = label[1]
              end = label[2]
              constituent_len = end - start
              if constituent_len >= 1:
                        label_vector = torch.zeros(constituent_len,len(labels))

                        fwd_charIDs, bwd_charIDs = data.words2charIDvec(words[start:end])
                        fwd_charIDs = model.long_tensor(fwd_charIDs)
                        bwd_charIDs = model.long_tensor(bwd_charIDs)
                        
                        if constituent_len >= 2:
                            label_tview = label_vector.view(constituent_len, len(labels))
                            label_tview[end - start - constituent_len][span_no] = data.labelID(label)
                        else:
                            label_tview = label_vector.view(constituent_len,len(labels))
                            label_tview[end - start -1][span_no] = data.labelID(label)

                        # run the model
                        if type(model) is Parser:
                            labelscores = model(fwd_charIDs, bwd_charIDs, len(labels))
                            #print("Labelvector: ")
                            #print(label_tview.squeeze(0))
                        
                            loss = loss_function(labelscores, label_tview)

                            # compute the label predictions
                            _, predicted_labelIDs = labelscores.max(-1)
                            _, pre_label_tview = label_tview.max(-1)

Abspeichern des Netzwerks und der Parameterdatei, Shuffle der Daten nach jeder Epoche:

for epoch in range(args.epochs):

      random.shuffle(data.train_parses)  # data is shuffled after each epoch
      loss, accuracy = run_parser(data.train_parses, data, model, optimizer)
      print("Epoch:", epoch+1, file=sys.stderr)
      print("TrainLoss: %.0f" % loss, "TrainAccuracy: %.2f" % accuracy, file=sys.stderr)
      sys.stderr.flush();

      if epoch >= args.burn_in_epochs:
         scheduler.step()
         
      loss, accuracy = run_parser(data.dev_parses, data, model)
      print(epoch+1, "DevLoss: %.0f" % loss, "DevAccuracy: %.2f" % accuracy)
      sys.stdout.flush()

      ### keep the model which performs best on dev data
      if max_accuracy < accuracy:
         max_accuracy = accuracy

         with open(args.path_param+".hyper", "wb") as file:
            pickle.dump(hyper_params, file)
         
         if model.on_gpu():
            model = model.cpu()
            torch.save(model.state_dict(), args.path_param+".rnn")
            model = model.cuda()
         else:
            torch.save(model.state_dict(), args.path_param+".rnn")

Anwendung

Die Anwendung des Parser erfolgt über ein kommandobasiertes Shellskript, welches die Pythondatei aufruft. Dazu müssen textbasierte mittelhochdeutsche Texte mit zeilenweiser Satztokenisierung als Eingabe und der gewünschte Ausgabepfad angegeben werden.

Zur Berechnung der syntaktischen Kategorien des Eingabetextes wird mithilfe des trainierten neuronalen Netzes und dem Viterbi-Algorithmus jeweils die beste Zerlegung aller möglichen Spans und die beste syntaktische Kategoeir berechnet. der best-bewertetste Parsebaum wird dann ausgegeben.

Berechnung der besten Bewertung und der besten Kategorie über Viterbi-Algorithmus:

def parse(sentences, data, model):
    for i, words in enumerate(sentences):
        # for all constituents
        for constituent_len in range(1,len(words)-1): 
            # for all startpositions
            for start in range(len(words)-constituent_len): 
                # end position
                end = start+constituent_len 
                
                for label in data.ID2label:
                     if constituent_len == 1:
                        fwd_charIDs, bwd_charIDs = data.words2charIDvec(words[start:end])
                        fwd_charIDs = model.long_tensor(fwd_charIDs)
                        bwd_charIDs = model.long_tensor(bwd_charIDs)
                        # Score of category
                        labelscores = model(fwd_charIDs, bwd_charIDs, len(words)-constituent_len)
                        # best category
                        _, labelID = labelscores.max(dim=-1)
                        best_label = torch.argmax(labelID)
                        best_label = data.ID2label(label)
                    
                     else:
                        left_encodings = []
                        right_encodings = []
                        for split in range(start + 1, end):
                           # get split scores
                           left_fwd_charIDs, left_bwd_charIDs = data.words2charIDvec(words[start:split])
                           left_fwd_charIDs = model.long_tensor(left_fwd_charIDs)
                           left_bwd_charIDs = model.long_tensor(left_bwd_charIDs)
                           left_scores = model(left_fwd_charIDs, left_bwd_charIDs, len(words)-constituent_len)
                           left_encodings.append(left_scores)
                           right_fwd_charIDs, right_bwd_charIDs = data.words2charIDvec(words[split:end])
                           right_fwd_charIDs = model.long_tensor(right_fwd_charIDs)
                           right_bwd_charIDs = model.long_tensor(right_bwd_charIDs)
                           right_scores = model(right_fwd_charIDs, right_bwd_charIDs, len(words)-constituent_len)
                           right_encodings.append(right_scores)
                        _, labelID = left_scores.max(dim=-1)

                        #Label of splitted constituents
                        best_label = torch.argmax(labelID)
                        best_label = data.ID2label(best_label)
                           
                        _, labelID_right = right_scores.max(dim=-1)
                        label_right = torch.argmax(labelID_right)
                        label_right = data.ID2label(label_right)
                            
                        split_score=torch.argmax(left_encodings+right_encodings)
                         # Beste Zerlegung
                return  build_parse(start, end, best_label, words, split_score)   

Parser herunterladen

Der syntaktische Parser für das Mittelhochdeutsche kann hier heruntergeladen werden.