MorphoDiTa - zpracování textu

K používání MorphoDiTy lokálně jsou potřeba dvě věci:

  1. MorphoDiTa
  2. jazykové modely

Aktuální verzi stáhneme z oficiálního GitHubu. V archivu nalezneme jak zdrojové kódy, tak již zkompilované binárky pro Windows, Linux a MacOS.

Jazykové modely pro češtinu jsou ke stažení na stránkách LINDATu. V archivu je několik různých modelů, nás bude zajímat primárně jen jeden - ten se všemi slovními druhy a s diakritikou.

Třída pro zpracování vstupu

Vše potřebné máme připravené, pojďme se tedy pustit do psaní nástroje, který vstupní text prožene MorphoDiTou a vrátí XML soubor s rozborem.

Vytvoříme si tedy novou složku pro náš projekt (morphy) a v ní podsložku morphodita.

Z dříve stažených souborů budeme potřebovat run_tagger.exe z MorphoDiTY a czech-morfflex-pdt-161115.tagger z jazykového modelu, které nakopírujeme do složky morphodita.

Terminal
Output
morphy/
├─ morphodita/
│  ├─ run_tagger.exe
│  ├─ czech-morfflex-pdt-161115.tagger
├─ run.py

Potřebné věci jsou na svém místě a my se můžeme pustit do tagování. Většinu práce za nás odvedl někdo jiný (konkrétně Milan Straka a Jana Straková), my se pokusíme si to celé hlavně zabalit do pohodlnějšího API.

Samotné otagování je otázkou zavolání programu run_tagger.exe:

Terminal
Command
morphodita\run_tagger.exe --output=xml morphodita\czech-morfflex-pdt-161115.tagger input.txt:output.xml
Output
Loading tagger: done
Tagging done in 0.011 seconds.

My ale chceme pracovat s Pythonem, proto si vytvoříme obslužnou třídu pro tagger. Cílové API pro používání by mělo vypadat následovně:

run.py
from morphy.tagger import Tagger

tagger = Tagger('morphodita/run_tagger.exe', 'morphodita/czech-morfflex-pdt-161115.tagger')

tagger.output = 'xml'

code = tagger.tag_file("input.txt", "output.xml")

Pojďme na to:

tagger.py
import os
import subprocess

from .output_format import Json, Vertical, Xml

# Aktualni binarka a model
LATEST_EXECUTABLE = 'morphodita/run_tagger.exe'
LATEST_TAGGER = 'morphodita/czech-morfflex-pdt-161115.tagger'

class Tagger:
    # Mozne hodnoty jednotlivych parametru
    PARAM_INPUT = ['untokenized', 'vertical']
    PARAM_CONVERT_TAGSET = ['pdt_to_conll2009', 'strip_lemma_comment', 'strip_lemma_id']
    PARAM_DERIVATION = ['none', 'root', 'path', 'tree']
    PARAM_GUESSER = ['0', '1']
    PARAM_OUTPUT = ['vertical', 'xml']
    
    OUTPUT_FORMAT = ['json', 'vertical', 'xml']
    
    OUTPUT_FORMAT_JSON = 'json'
    OUTPUT_FORMAT_XML = 'xml'

    FORMATTERS = {
        'json': Json,
        'vertical': Vertical,
        'xml': Xml,
    }
    
    def __init__(self, executable=LATEST_EXECUTABLE, tagger=LATEST_TAGGER) -> None:
        # Binarku a jazykovy model predame jako zavislost
        # Nejdrive ale zjistime, jestli soubory existuji
        if not os.path.isfile(executable):
            raise FileNotFoundError("Executable file not found: {}".format(executable))
        
        if not os.path.isfile(tagger):
            raise FileNotFoundError("Tagger file not found: {}".format(tagger))

        self._executable = executable
        self._tagger = tagger
        
        # Nastaveni vychozi hodnoty parametru
        self._params = {
            'input' : None,
            'convert_tagset' : None,
            'derivation' : None,
            'guesser' : None,
            'output' : None,
        }

Abychom měli možnost nastavit parametry pro binárku a zároveň nepřišli o možnost základní validace hodnost, uděláme parametry jako @property třídy:

tagger.py
@property
def input(self):
    return self._params['input']

@input.setter
def input(self, input):
    if input not in self.PARAM_INPUT:
        raise ValueError("Invalid 'input' value: {}. Allowed values: {} ".format(input, ', '.join(self.PARAM_INPUT)))
    self._params['input'] = input

# Stejnym zpusobem pote vytvorime i property pro convert_tagset, derivation, guesser a output.

Nyní přikročíme k samotným funkcím, které spustí tagování. Chci mít dostupnou možnost otagovat jak vstup ze souboru, tak pouze řetězec z kódu, takže si vytvořím dvě metody - tag_file a tag_string.

Zároveň chci vedle XML a vertikálního plaintextu vytvářet i JSON, budeme si tedy muset vytvořit pro každý formát vlastní formátovač.

Otagování souboru

V principu se jedná o jednoduchou věc. Funkce přijme název vstupního a výstupního souboru, na pozadí spustí MorphoDiTu, její výstup prožene přes formátovač a uloží do výstupního souboru.

Výsledek tedy bude vypadat následovně:

tagger.py
def tag_file(self, input_file, output_file):
    cmd = self._create_command()
    cmd.append('{}'.format(input_file))
    result = subprocess.run(cmd, capture_output=True)
    
    if result.returncode != 0:
        raise Exception("Error while tagging file: {}".format(result.stderr))
    else:
        with open(output_file, 'w', encoding="utf8") as f:
            f.write(self._format_output(result.stdout, self._output_format))
    return result.returncode 

Pojďme si to ale rozebrat podrobněji. Ihned na začátku je metoda _create_command, která vytvoří celý příkaz pro spuštění taggeru:

tagger.py
def _create_command(self):
    args = [self._executable]
    for k, v in self._params.items():
        if v:
            args.append('--{}={}'.format(k, v))
    args.append(self._tagger)
    return args

Následně přes modul subprocess spustíme takto složený příkaz a výstup uložíme do proměnné. MorphoDiTa posílá veškeré zprávy mimo samotný výstup na stderr, můžeme si tedy bez problému sáhnout do stdout.

I když je možné nechat soubor s výstupem nechat uložit přímo MorphoDiTu, vzhledem k tomu, že s tímto výstupem potřebujeme dále pracovat, si ho pouze uložíme do proměnné.

O zformátování výstupu do požadovaného formátu se nakonec stará metoda _format_output, která si vytáhne jeden z podporovaných formátovačů a nechá ho zpracovat výstup.

tagger.py
def _format_output(self, output, output_format):
    if not output_format in self.OUTPUT_FORMAT:
        raise ValueError("Invalid 'output_format' value: {}. Allowed values: {} ".format(output_format, ', '.join(self.OUTPUT_FORMAT)))
    
    formatter = self.FORMATTERS[output_format]()
    
    return formatter.output(output)

Výsledek již pouze uložíme do cílového souboru.

Otagování řetězce

Pro kratší texty je možné předat k otagování řetězec v proměnné a vyhnout se tak vytváření souboru. V principu to funguje úplně stejně jako při tagování souboru, jen se zadaný řetězec pošle na stdin a nechá se zpracovat MorphoDiTou. Následně je pouze zformátován do požadované podoby.

tagger.py
def tag_string(self, text):
    cmd = self._create_command()
    result = subprocess.run(cmd, capture_output=True, input=text.encode('utf-8'))

    if result.returncode != 0:
        raise Exception("Error while tagging string: {}".format(result.stderr))
    else:
        return self._format_output(result.stdout, self._output_format)

Formát výstupu

MorphoDiTa v základu podporuje XML a vertikální plaintext. Nicméně oboje potřebuje trochu "uhladit" a navíc chceme mít možnost exportovat do JSON.

XML

Zásadním problémem v defaultním XML je, že není obalen jedním root elementem. U jedné věty to nevadí, v případě více vět ale zpracování takového XML vyhazuje chybu:

Terminal
Output
ParseError: junk after document element: line X, column Y

Proto si vytvoříme vlastní formátovač, který bude zpracovávat XML a přidávat do něj root element:

xml.py
# morphy.output_format.xml
class Xml:
    XML_ROOT_ELEMENT_OPEN = '<morphy>'
    XML_ROOT_ELEMENT_CLOSE = '</morphy>'

    def output(self, text: str) -> str:
        return "{}{}{}".format(
            self.XML_ROOT_ELEMENT_OPEN,
            text.decode('utf-8').strip(),
            self.XML_ROOT_ELEMENT_CLOSE,
        )

Vertikální plaintext

V případě vertikálního plaintextu je to jednodušší, stačí pouze sjednotit konce řádků a na nic dalšího není potřeba sahat:

vertical.py
# morphy.output_format.vertical
class Vertical:
    def output(self, text: str) -> str:
        return text.decode('utf-8').strip().replace('\r\n', '\n')

JSON

JSON jako takový není v základu podporován, my si ho tedy musíme vytvořit sami. Jako vstup použijeme XML formát, proiterujeme jej a převedeme na list, který následně uložíme jako JSON:

json.py
# morphy.output_format.json
import json
import xml.etree.ElementTree as ET

from .xml import Xml

class Json:
    SENTENCES_PATH = './sentence'

    def output(self, text: str) -> str:
        xml = Xml().output(text)
        root = ET.fromstring(xml)
        
        sentences = []
        for s in root.findall(self.SENTENCES_PATH):
            sentence = []
            for t in s:
                token = {
                    'token': t.text,
                    'lemma': t.attrib['lemma'],
                    'tag': t.attrib['tag'],
                }
                sentence.append(token)
            sentences.append(sentence)
        return json.dumps(sentences, ensure_ascii=False)
Další kapitoly
  • MorphoDiTa - slovní druhy
  • MorphoDiTa - zpracování textu
  • MorphoDiTa - představení