Jak w czystym Python’ie dokonać tokenizacji tekstu na ngramy oraz jak wygenerować słownik z wszystkimi możliwymi ngramami.
W wielu zadaniach NLP (Natural Languge processing) stajemy przed problemem wygenerowania słownika, czyli listy cząstek (tokenów) zdania potrzebnych do zakodowania naszego tekstu do formy liczbowej. Najczęściej tokeny utożsamiamy z wyrazami, bądź frazami (np. Bielsko-Biała).
Ostatnie prace w MT (machine translation) wymogły stosowanie cząstek wyrazów najczęściej występującej w korpusie tekstu. Słownik taki składa się z 1,2,3,4 itd literowych tokenów z których można złożyć całe słowo. Innym ekstremalnym przykładem jest operowanie tylko na pojedynczych literach. W wielu zdaniach NLP (machine translation, czy korekta błędów) okazuje się, że równie dobrze spisują się 2,3- ngramy znaków.
Przeanalizujmy przykład. Załóżmy że dysponujemy słownikiem składającym się z tokenów [’a’, 'b’, 'c’, …’z’]. Na jego podstawie przykładowy wyraz kodujemy przy pomocy indeksów występowania tokenów w tym słowniku.
Słowo: „aabbc” zakodujemy więc jako tablicę liczb [0,0,1,1,2].
Obecnie wiele bibliotek jest w stanie za nas zrobić tego typu kodowanie, ale zależało mi na tym aby zakodować tekstu w postaci ngramów, czyli zbitek 2,3,4 znakowych na potrzeby prac przy naszym projekcie korektora tekstu – GoodWrite.pl. Można to oczywiście zrobić z wykorzystaniem sciki-learn:
niestety trudno było mi to zastosować do znaków i następnie zintegrować z kodem w Pytorch.
Ngram tokenizer w Pytchon
Poniżej zamieszczam 4 funkcje, których zadaniem jest tokenizacja tekstu na ngramy o dowolnej wielkości (2,3,4 itd) oraz generowanie wszystkich możliwych ngramów występujących w tekście. Obie wersje funkcji w postaci przyjmujących sekwencję oraz generator.
#%% from collections import Counter, OrderedDict from itertools import zip_longest , tee ## ngram iterators and tokenizers, working on list or generators def ngram_tokenizer_iter(iterable, n, fillvalue=''): "generuje pary znaków obok siebie, tokenizuje [abcd]->ab, cd dla tekstu przekazanego w formie generatora" # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" args = [iter(iterable)] * n zip_tuples = zip_longest(*args, fillvalue=fillvalue) for tup in zip_tuples: yield "".join(tup) def ngram_tokenizer(ngrams): '''generuje pary znaków obok siebie, tokenizuje [abcd]->ab, cd ''' def func(text): return (text[pos:pos + ngrams] for pos in range(0, len(text), ngrams)) return func def ngram_vocab_gen(ngrams): '''generuje wszystkie ngramy [abcd]->ab, bc, cd, d. Wersja pracująca z sekwencją (listą) ''' def func(text): return (text[i:i+ngrams] for i in range(len(text)+1-ngrams)) return func def ngram_vocab_gen_iter(iterator,ngrams): '''generuje wszystkie ngramy [abcd]->ab, bc, cd, d. Wersja pracująca z generatorem ''' iter_tuple = tee(iterator, ngrams) nested_iter_next=0 list_of_iters = [] for i,one_iter in enumerate(iter_tuple): for _ in range(nested_iter_next): next(one_iter,"") list_of_iters.append(one_iter) nested_iter_next+=1 for tup in zip(*list_of_iters): yield "".join(tup)
Po pierwsze, zauważcie że sama tokenizacja i generowanie ngramów to dwie odrobinę różne rzeczy. Funkcja tokenizująca potrzebna jest aby słowo zamienić na ngramy i ewentualnie łatwo odwrócić ten proces. Natomiast funkcja do generowania ngramów potrzebna jest do wygenerowania słownika. Z tej samej ilości tekstu wygeneruje szybciej wszystkie możliwe ngramy.
Funkcjie ngram_tokenizer i ngram_vocab_gen przyjmują jako parametr sekwencję (najczęściej listę) bo wymagane jest określenie rozmiaru sekwencji (funkcja len). Obie te funkcje mają swój odpowiednik przyjmujący jako parametr generator ngram_tokenizer_iter ngram_vocab_gen_iter.
Wykorzystanie funkcji zależy od waszego przypadku, skąd pobierany jest tekst. Czy jest go dużo i podawany jest z wykorzystaniem yield czy po prostu wczytany do wielkiej listy.
Jak użyć ngram tokenizera i jak wyglądają wyniki tokenizacji
Przetestujmy czy wyniki tokenizacji w obu implementacjach się pokrywają
#%% dataset_text = "aabbcc ddaaa aacca caca baaba baac " #dataset z znaków a,b,c aby było prościej :) # dataset_text = "Twój długi tekst, najczęściej scalony cały dataset to jednego stringa, albo jego część, albo generator odczytujący z pliku linie po lini " #%% All possible char-bi-grams a=list(ngram_vocab_gen(2)(dataset_text)) print(f'{len(a)} {a[0:10]}') #%% a=list(ngram_vocab_gen_iter(dataset_text,2)) print(f'{len(a)} {a[0:10]}') #%% bi-gram tokenizer a=list(ngram_tokenizer_iter(dataset_text,2)) print(f'{len(a)} {a[0:10]}') #%% a=list(ngram_tokenizer(2)(dataset_text)) print(f'{len(a)} {a[0:10]}')
Zwróccie uwagę na inny sposób wywołania metod oraz różnicę pomiędzy tokenizacją a generowaniem ngramów. W przypadku ngram generatora ilość tokenów jest większa (i tak powinno być)
Sprawdźmy jak szybko to działa
Sprawdźmy różnice w czasie działania wykrozystując pakiet timeit
#%% testing speed and accuracy of ngram tokenization and ngram seed generation import timeit import numpy as np SETUP_CODE = ''' from __main__ import ngram_tokenizer_iter, ngram_tokenizer,ngram_vocab_gen, ngram_vocab_gen_iter from __main__ import dataset_text ''' CODE1=''' a=list(ngram_tokenizer_iter(dataset_text,2)) ''' CODE2=''' a=list(ngram_tokenizer(2)(dataset_text)) ''' CODE3=''' a=list(ngram_vocab_gen(2)(dataset_text)) ''' CODE4=''' a=list(ngram_vocab_gen_iter(dataset_text,2)) ''' print(f'{CODE1} time={np.mean(timeit.repeat(CODE1,SETUP_CODE, repeat=3,number=10))}') print(f'{CODE2} time={np.mean(timeit.repeat(CODE2,SETUP_CODE, repeat=3,number=10))}') print(f'{CODE3} time={np.mean(timeit.repeat(CODE3,SETUP_CODE, repeat=3,number=10))}') print(f'{CODE4} time={np.mean(timeit.repeat(CODE4,SETUP_CODE, repeat=3,number=10))}')
Po uruchmieniu nie powinno być dużych różnic w czasie działania ale nie testowałem tego jeszcze na dużej ilości.
Poniżej przykładowy wynik czasu działania dla tekstu od długości 326500 znaków.
Podsumowanie
We wpisie przedstawiłem po dwie implementacja ngram tokenizera oraz ngram generatora w czystym Pythonie. Czas działania funkcji jest podobny, ale nie testowałem ich na duuuużych danych, więc z chęcią przeczytam o waszych eksperymentach. Jeżeli widzisz możliwość ulepszenia to skomentuj, będzie dla potomnych 🙂
Link do pełnego pliku z kodem https://gist.github.com/ksopyla/d9d7bf1eda1a426ecff9fe2b40969dbc
Jeżeli uważasz ten wpis za wartościowy to Zasubskrybuj bloga. Dostaniesz informacje o nowych artykułach.
Image by Hans Braxmeier from Pixabay