Tworzenie usług NLP
W tym dokumencie opisano proces tworzenia usług NLP (nlpworkers).
Automatycznie generowana dokumentacja
Dostępna pod adresem: http://nlpworkers.pages.clarin-pl.eu/nlp_ws
Informacje podstawowe
Tworzenie usług NLP w ramach architektury CLARIN polega na stworzeniu klasy dziedziczącej i zdefiniowaniu kilku metod, które były abstrakcyjne. Najważniejszą z nich jest funkcja process
, która przyjmuje trzy argumenty: ścieżkę do pliku wejściowego, opcje (klucz-wartość) i ścieżka do pliku wyjściowego (programista jest odpowiedzialny za stworzenie pliku o przekazanej ścieżce). Standardowo pliki wejściowe i wyjściowe znajdują się na dysku sieciowym (aktualnie używamy prot. SMB - katalog jest najczęściej podmontowywany w /samba). Parametry z którymi ma zostać wywołana funkcja process
są przesyłane za pośrednictwem systemu kolejkowego Rabbit (jest on również używany do łączenia usług w potoki przetwarzania). Każda usługa NLP powinna mieć swoje repozytorium w https://gitlab.clarin-pl.eu/nlpworkers.
Aktualnie istnieją trzy wersje wspomnianych bibliotek (zawierających wspomnianą wyżej klasę abstrakcyjną): dla języka Python, Java i C++. Niżej opisano proces tworzenia usługi w każdym z wymienionych języków.
Tworzenie usługi w języku PYTHON
Aby stworzyć usługę NLP w języku Python niezbędne jest zainstalowanie paczki nlp_ws (jest ona dostępna z poziomu naszego serwera PYPI: https://pypi.clarin-pl.eu). Aby ją zainstalować należy wprowadzić komendę (najlepiej w wirtualnym środowisku) pip install --index-url https://pypi.clarin-pl.eu/simple/ nlp_ws
(lepszym rozwiązaniem jest umieszczenie nazwy biblioteki wraz z innymi używanymi bibliotekami w pliku requirements.txt
i użycie komendy pip install --index-url https://pypi.clarin-pl.eu/simple/ -r requirements.txt
).
Niżej przedstawiono prosty przykład usługi, która kopiuje pliki z jednego katalogu do drugiego:
"""Implementetion of dummy worker."""
import nlp_ws
class Worker(nlp_ws.NLPWorker):
"""Class that implements example worker."""
def process(self, input_file: str, task_options: dict, output_file: str) -> None:
"""Implementation of example tasks that copies files."""
shutil.copy(input_file, output_file)
if __name__ == '__main__':
nlp_ws.NLPService.main(Worker)
Jak wspomniano wyżej, najważniejszą funkcją, którą trzeba zdefiniować, jest funkcja process
- w powyższym przykładzie zajmuje się ona kopiowaniem plików z katalogu input_file
do output_file
(plik wyjściowy jest tworzony przez komendę shutil.copy
). Klasa nlp_ws.NLPWorker
posiada więcej przydanych funkcji (funkcje są dobrze udokumentowane w kodzie biblioteki https://gitlab.clarin-pl.eu/nlpworkers/nlp_ws:
-
static_init
- funkcja, która pozwala na inicjalizację współdzielonych zasobów - wszystko co przypiszemy do zmiennejcls
zostanie spakowane (pickle) i wysłane do każdego procesu (funkcje process są uruchamiane w osobnych procesach) -
init
- inicjalizacja każdego Workera (funkcja działa w sposób zbliżony do standardowego konstruktora init -
close
- funkcja pozwalajaca na zwolnienie zarezerwowanych zasobów przez danego Workera (w związku z istnieniem garbage collectora jest ona rzadko używana - ...
Podzadania nlp (od wersji 2.1)
Biblioteka umożliwia korzytanie z podzadań nlp (klasa SubTask), które są pełnoprawnymi zapytaniami LPMN wykonywanymi wewnątrz metody process
. Aby wykorzytać podzadanie należy:
- Zaimportować klase SubTask z biblioteki nlp_ws
- Włączyć obsługę podzadań, dodając
SubTask.turn_on()
przed definicją klasy workera - W metodzie process wywołać obsługę podzadania/podzadań poprzez:
- Utworzenie podzadania:
l_subtask = SubTask(input_path, ["wcrft2", "liner2"])
- Uruchomienie:
l_subtask.run()
- Pozyskanie ścieżki do wyniku:
t_subtask.get_output_path()
Podzadania mogą być wywoływane:
- wraz z definicją opcji, zadanie jako słownik, np:
[{'tab2text':{"files":"B", "texts":"E"}}]
-
'tab2text'
- nazwa narzędzia -
{"files":"B", "texts":"E"}
- opcje
-
- bez definicji opcji, zadanie jako tekst, np:
["wcrft2"]
-
"wcrft2"
- nazwa narzedzia
-
- po kolei, np:
["wcrft2", "liner2"]
wcrft2
liner2
Przykładowe użycie podzadań:
#!/usr/bin/python
"""Implementation of dummy worker."""
import shutil
import logging
import time
from nlp_ws import NLPWorker, NLPService, SubTask
import json
log = logging.getLogger(__name__)
SubTask.turn_on()
class DummyWorker(NLPWorker):
"""Class that implements example worker with subtasks."""
def subtasks(self, input_path, task_options, output_path):
"""Worker task with subtasks.
:param input_path: input path
:type input_path: str
:param task_options: task options
:type task_options: dict
:param output_path: output path
:type output_path: str
"""
l_subtask = SubTask(input_path, ["wcrft2", "liner2"])
l_subtask.run(blocking=False)
t_subtask = SubTask(input_path, ["textclass"])
t_subtask.run(blocking=False)
self.update_progress(0.1)
time.sleep(0.1)
log.info("Dummy waits for results")
l_result = l_subtask.get_output_path()
self.update_progress(0.3)
t_result = t_subtask.get_output_path()
self.update_progress(0.5)
log.info("Result paths {} {}".format(l_result, t_result))
with open(t_result, "rt") as f:
text = json.loads(f.read())
log.info(text)
self.update_progress(1)
def process(self, input_path, task_options, output_path):
"""Implementation of process method inherited from NLPworker.
:param input_path: input path
:type input_path: str
:param task_options: task options
:type task_options: dict
:param output_path: output path
:type output_path: str
"""
log.info("Processing!!!")
self.subtasks(input_path, task_options, output_path)
if __name__ == '__main__':
NLPService.main(DummyWorker)
Podzadania sent (od wersji 2.2)
Biblioteka umożliwia korzytanie z podzadań sent (klasa Sentence), które są pełnoprawnymi zapytaniami LPMN wykonywanymi wewnątrz metody process
. Aby wykorzytać podzadanie należy:
- Zaimportować klase Sentence z biblioteki nlp_ws
- Włączyć obsługę podzadań, dodając
Sentence.turn_on()
przed definicją klasy workera - W metodzie process wywołać obsługę podzadania/podzadań poprzez:
- Utworzenie podzadania:
sentences = ["My name is Jonas E. Smith. Please turn to p. 55.", "My name is Jonas E. Smith. Please turn to p. 55."] l_subtask = Sentence(sentences, 'fast_kgr10')
- Uruchomienie:
l_subtask.run()
- Pozyskanie wyniku:
t_subtask.get_results()
Przykładowe użycie podzadań:
#!/usr/bin/python
"""Implementation of dummy worker."""
import shutil
import logging
import time
from nlp_ws import NLPWorker, NLPService, Sentence
import json
log = logging.getLogger(__name__)
Sentence.turn_on()
class DummyWorker(NLPWorker):
"""Class that implements example worker with subtasks."""
def subtasks(self, input_path, task_options, output_path):
"""Worker task with subtasks.
:param input_path: input path
:type input_path: str
:param task_options: task options
:type task_options: dict
:param output_path: output path
:type output_path: str
"""
sentences = ["My name is Jonas E. Smith. Please turn to p. 55.", "My name is Jonas E. Smith. Please turn to p. 55."]
l_subtask = Sentence(sentences, 'fast_kgr10')
l_subtask.run(blocking=False)
self.update_progress(0.1)
time.sleep(0.1)
log.info("Dummy waits for results")
l_result = l_subtask.get_results()
self.update_progress(0.3)
log.info("Result {}".format(l_result))
self.update_progress(1)
def process(self, input_path, task_options, output_path):
"""Implementation of process method inherited from NLPworker.
:param input_path: input path
:type input_path: str
:param task_options: task options
:type task_options: dict
:param output_path: output path
:type output_path: str
"""
log.info("Processing!!!")
self.subtasks(input_path, task_options, output_path)
if __name__ == '__main__':
NLPService.main(DummyWorker)
Sugerowana struktura projektu
src/ - źródła aplikacji
tests/ - testy
main.py - skrypt wejściowy
config.ini - plik konfuguracyjny (opisany niżej)
Dockerfile - definicja budowanie obrazu Docker
.gitlab-ci.yml - definicja potoku CI
.gitignore - pliki mające być ignorowane przez system git
tox.ini - konfiguracja narzędzia tox
requirements.txt - wymagane biblioteki
Plik config.ini
Każda usługa powinna zawierać plik konfiguracyjny config.ini
. Jego podstawowa wersja wygląda w następujący sposób:
[service]
tool = nazwa_uslugi
root = /samba/requests/
rabbit_host = addr
rabbit_user = test
rabbit_password = test
[tool]
workers_number = 1
[logging]
port = 9981
local_log_level = INFO
[logging levels]
__main__ == INFO
Plik ten zawiera informacje nazwie usługi (pod taką nazwą usługa będzie widoczna w systemie), ścieżce z plikami wejściowymi i wyjściowymi, login i hasło pozwalające połączyć się z systemem Rabbit i liczbę procesów, które mają zostać uruchomione w ramach działania usługi (workers_number
). Ten plik można rozbudowywać dodając własne sekcje.
Obsługa zmiennych środowiskowych
Możliwe jest zdefiniowanie zmiennych środowiskowych które nadpiszą zmienne wprowadzone za pomocą pliku config.ini
a także całkowite zrezygnowanie z pliku config.ini
i zdefiniowanie wszystkich wymaganych zmiennych jako zmienne środowiskowe.
Struktura zmiennych środowikowych:
Zmienna powinna się rozpoczynać od opisu sekcji, jednego z poniższych:
CFG_S_SERV - odpowiada sekcji [service]
CFG_S_TOOL - odpowiada sekcji [tool]
CFG_S_LOG - odpowiada sekcji [logging]
CFG_S_LOGLEVELS - odpowiada sekcji [logging_levels]
Dalsza część zmiennej to _OPT_NAZWA_OPCJI, predefiniowane opcje:
Dla sekcji service:
CFG_S_SERV_OPT_ROOT
CFG_S_SERV_OPT_TOOL
CFG_S_SERV_OPT_QUEUE_PREFIX
CFG_S_SERV_OPT_RABBIT_HOST
CFG_S_SERV_OPT_RABBIT_USER
CFG_S_SERV_OPT_RABBIT_PASSWORD
CFG_S_SERV_OPT_IS_PRODUCTION
Dla sekcji tool:
CFG_S_TOOL_OPT_WORKERS_NUMBER
Dla sekcji log:
CFG_S_LOG_OPT_PORT
CFG_S_LOG_OPT_LOGFILE_NAME
CFG_S_LOG_OPT_LOGFILE_MAXSIZE
CFG_S_LOG_OPT_LOGFILE_MAXBACKUPS
CFG_S_LOG_OPT_LOG_FORMAT
CFG_S_LOG_OPT_LOCAL_LOG_LEVEL
Dostępne jest także definiowanie własnych zmiennych, takie zmienne powinny mieć strukture taką jak zmienne predefiniowane. Przykładowo zmienna o nazwie my_awesome_variable w sekcji service:
CFG_S_SERV_OPT_MY_AWESOME_VARIABLE
config.ini
):
Wymagane zmienne środowiskowe (w przypadku braku pliku CFG_S_SERV_OPT_ROOT
CFG_S_SERV_OPT_TOOL
CFG_S_SERV_OPT_RABBIT_HOST
CFG_S_SERV_OPT_RABBIT_USER
CFG_S_SERV_OPT_RABBIT_PASSWORD
Konteneryzacja
Każda usługa powinna posiadać plik Dockerfile, który pozwala na budowę obrazu Docker. Plik ten powinien być budowany na podstawie jednego z predefiniowanych obrazów (posiadają one informacje między innymi o naszym serwerze PYPI, czy APT (instalacja narzędzi nie wymaga żadnych specjalnych akcji). Lista obrazów bazowych jest dostępna pod adresem https://gitlab.clarin-pl.eu/dockers
Niżej zamieszczono prosty przykład pliku Dockerfile (obraz clarinpl/cuda-python:3.6 pozwala na używanie karty graficznej - jeżeli ta funkcja jest zbędna należy użyć innego obrazu)
FROM clarinpl/cuda-python:3.6
WORKDIR /home/worker
COPY ./src ./src
COPY ./cmc_service.py .
COPY ./requirements.txt .
COPY ./models/CMC ./models/CMC
COPY ./entrypoint.sh .
RUN apt update && apt install -y g++ gdb
RUN git clone https://github.com/facebookresearch/fastText.git && \
cd fastText && \
python3.6 -m pip install . && \
cd .. && \
rm -rf fastText
RUN python3.6 -m pip install -r requirements.txt
RUN ["chmod", "+x", "./entrypoint.sh"]
CMD ["./entrypoint.sh"]
Potok CI/CD
Gitlab pozwala na tworzenie prostych potoków CI/CD (https://docs.gitlab.com/ee/ci/). Każdy nowy projekt powinien zawierać taki plik, który uruchamia sprawdzanie jakości kodu (code style), uruchamia testy, budowanie i wysylanie obrazu Docker (ten krok powinien być uruchamiany jedynie na branchu master). Niżej przedstawiono prosty przykład CI:
image: clarinpl/python:3.6
cache:
paths:
- .tox
stages:
- check_style
- build
before_script:
- pip install tox==2.9.1
pep8:
stage: check_style
script:
- tox -v -e pep8
docstyle:
stage: check_style
script:
- tox -v -e docstyle
build_image:
stage: build
image: docker:18.09.7
only:
- master
services:
- docker:18.09.7-dind
before_script:
- ''
script:
- docker build -t clarinpl/websim .
- echo $DOCKER_PASSWORD > pass.txt
- cat pass.txt | docker login --username $DOCKER_USERNAME --password-stdin
- rm pass.txt
- docker push clarinpl/websim