MorphoDiTa - zpracování textu
K používání MorphoDiTy lokálně jsou potřeba dvě věci:
- MorphoDiTa
- 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
.
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
:
morphodita\run_tagger.exe --output=xml morphodita\czech-morfflex-pdt-161115.tagger input.txt:output.xml
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ě:
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:
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:
@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ě:
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:
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.
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.
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:
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:
# 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:
# 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:
# 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)