Wyszukiwanie fragmentów tekstu – wyrażenia regularne vs parser

0

Problem

Chciałbym napisać w Javie program, który wyciąga z tekstu cytaty, oraz nazwę osób je mówiących.

Wydaje mi się to rzeczą specyficzną dla danego autora, toteż postanowiłem skoncentrować się na utworze "Pan Tadeusz". Założyłem, że będzie to probierz, czy ogólnie program działa, czy nie.

Do wyszukania w tekście odpowiednich fragmentów postanowiłem użyć wyrażeń regularnych. Miałem do wyboru też parser. Domyślam się, że wyrażenia regularne nie są najlepsze do wyszukiwania tekstu, ale ja stoję na stanowisku, że jeśli czegoś nie da się zrobić jakimś narzędziem, to dlatego, że ma się za mało wyobraźni – oraz po prostu je znam, a parsera nigdy nie pisałem.

Ale mam problem, ponieważ przy uruchomieniu programu pojawia mi się java.lang.StackOverflowError. Używam OpenJDK 11, ale kombinuję, że może to mieć coś wspólnego z tą funkcjonalnością Javy: https://bugs.java.com/bugdatabase/view_bug.do?bug_id=5050507, opisaną tutaj: https://www.javaworld.com/article/2077757/core-java/optimizing-regular-expressions-in-java.html?page=4

Pytanie

Czy kombinować dalej z wyrażeniami regularnymi, czy nie?
1. Jeśli tak, to jak poradzić sobie z tym błędem?
2. Jeśli nie, to jak inaczej to napisać?

Przykład

Dla tekstu

Dano trzecią potrawę. Wtem pan Podkomorzy,
Wlawszy kropelkę wina w szklankę panny Róży,
A młodszej przysunąwszy z talerzem ogórki,
Rzekł: "Muszę ja wam służyć, moje panny córki,
Choć stary i niezgrabny". Zatem się rzuciło
Kilku młodych od stołu i pannom służyło.

program powinien wyodrębnić (docelowo, bo jeszcze nie wszystko napisałem)

Wtem pan Podkomorzy,
Wlawszy kropelkę wina w szklankę panny Róży,
A młodszej przysunąwszy z talerzem ogórki,
Rzekł: "Muszę ja wam służyć, moje panny córki,
Choć stary i niezgrabny".

Mój aktualny kod

Wykomentowałem fragmenty niedotyczące mojego pytania.

// tu wczytywanie danych z wejścia do zmiennej inputString

List<SemanticMarker> semanticMarkersRegexes = new ArrayList<>(Arrays.asList(new SemanticMarker("powiedział(?:a|o)?"), new SemanticMarker("rzekł(?:a|o)?"), new SemanticMarker("mówił(?:a|o)?")));

String semanticMarkersRegexesAlternative = String.join("|", semanticMarkersRegexes.stream().map(x -> x.getMarker()).collect(Collectors.toList()));

// not-escaped regex: [^\n]*(semanticMarkersRegexesAlternative)(?:[^"\n]|[^"\n]\n(?!\n))+"(?:[^"\n]|[^"\n]\n(?!\n)|[^"\n]\n\n")+"
String semanticMarkerBeforeRegex = "(?is)[^\\n]*(" + semanticMarkersRegexesAlternative + ")(?:[^\"\\n]|[^\"\\n]\\n(?!\\n))+\"(?:[^\"\\n]|[^\"\\n]\\n(?!\\n)|[^\"\\n]\\n\\n\")+\"";

// tu dopasowywanie inputString względem semanticMarkerBeforeRegex

// not-escaped regex: (?is)"(?:[^"\n]|[^"\n]\n(?!\n)|[^"\n]\n\n")+"
String allQuotesRegex = "(?is)\"(?:[^\"\\n]|[^\"\\n]\\n(?!\\n)|[^\"\\n]\\n\\n\")+\"";

// tu dopasowywanie inputString względem allQuotesRegex

// not-escaped regex: (?is)"(?:[^"\n]|[^"\n]\n(?!\n)|[^"\n]\n\n")+"(?:[^"\n]|[^"\n]\n(?!\n))+(semanticMarkersRegexesAlternative)[^"\n]*
String semanticMarkerAfterRegex = "(?is)\"(?:[^\"\\n]|[^\"\\n]\\n(?!\\n)|[^\"\\n]\\n\\n\")+\"(?:[^\"\\n]|[^\"\\n]\\n(?!\\n))+(" + semanticMarkersRegexesAlternative + ")[^\"\\n]*";

// tu dopasowywanie inputString względem semanticMarkerAfterRegex

// tu wypisywanie danych na wyjście

Co ciekawe, Java wydaje się robić problem z negative lookahead. Gdy testuję tylko pierwsze wyrażenie regularne, semanticMarkerBeforeRegex, i oba fragmenty (?!\\n) zamieniam na [^\\n], Java zwraca wyniki (nie wiem co prawda, czy poprawne, ale sądzę, że to inna sprawa).

Jeśli kod wygląda skomplikowanie lub dziwnie, to dlatego, że nie jestem jeszcze dobry w Javie. Każdą uwagę przyjmę. :)


UPDATE

Może należałoby wspomnieć, że tekst, na którym dopasowywałem wyrażenie regularne, to cała pierwsza księga "Pana Tadeusza". Docelowo chciałbym, żeby długość tekstu nie miała znaczenia (program może wykonywać się i pół godziny, o to nie dbam; żeby tylko pamięci nie zabierał więcej niż, powiedzmy, 1 GB).

1

Wygląda na to, że zostałeś zdradzony przez regexpy i twój problem wynika z rekursji:
https://stackoverflow.com/questions/7509905/java-lang-stackoverflowerror-while-using-a-regex-to-parse-big-strings

Pierwszu z twoich linków to zresztą ten bład w jdk chyba. Na so jest pokazane jak zwiększyć stos. U Ciebie może wystarczyć to rozwiązanie.

1

Ogolnie w wyrazeniach regularnych (podobnie zreszta jak w parserach) rekurencja jest na porzadku dziennym, wiec StackOverflowError moze wystapic. np. PCRE radzi sobie z tym tak ze ma opcje skompilowania tak, ze symuluje wywolania rekurencyjnie na stercie. Ale w javie, nie korzystajac z bibliotek zewnetrznych, raczej zachowania nie zmienisz i dla duzych tekstow moze byc problem (oczywiscie w zaleznosci od konkretnego wyrazenia). Dla duzych tekstow wyrazenia tez moga zajmowac sporo pamieci i jest mozliwe ze sie w wymaganym 1GB nie zmiescisz (chociaz ja jeszcze takiej zajetosci nie widzialem. ale tylko kilkuktornie mniejsza owszem).

W opisie nie widze dokladnie co chcesz wyciagnac, ale z przykladu wynika ze po prostu zdanie w ktorym jest odpowedzni cytat. Jesli tak, i dla wyrazen jest zbyt skoplikowane zrobienie tego od razu, to latwo bedzie najpierw podzielic tekst na zdania (to mozna zrobic w jednej petli bez wyrazen) i dopiero wyrazenie uruchamiac na pojedynczym zdaniu.

Co do samych wyrazen to da sie je napisac (nieco) bardziej czytelnie, np:

		String sentenceRegexp =
			"(?xs)            " +
			"[^\\.\"]*        " +
			"(?: \"[^\"]*\" )*" +
			"[^\\.\"]*        " +
			"\\. 		      "
		;

To "powiedział", "rzekł", "mówił" jest podatne na bledy. Nawet zakladajac ze wiesz w jakim kodowaniu sa zrodla i jakie jest przekazywane do javac (praktyka pokazuje ze wiekszosc programistow tego nie wie), to z czyms takim sobie nie poradzi: ... tego dnia powiadał: "Dobrze, mój Tadeuszu ...

0
jarekr000000 napisał(a):

Wygląda na to, że zostałeś zdradzony przez regexpy

Nie będę płakać. Zakaszę rękawy. Co nie działa, trzeba ulepszyć. :)

Pierwszu z twoich linków to zresztą ten bład w jdk chyba. Na so jest pokazane jak zwiększyć stos. U Ciebie może wystarczyć to rozwiązanie.

To ciekawe rozwiązanie, nie pomyślałem. Wydaje mi się jednak zbyt niestandardowe; wolę raczej już nauczyć się pisania parsera…

Trzeźwy Kaczor napisał(a):

Dla duzych tekstow wyrazenia tez moga zajmowac sporo pamieci i jest mozliwe ze sie w wymaganym 1GB nie zmiescisz (chociaz ja jeszcze takiej zajetosci nie widzialem. ale tylko kilkuktornie mniejsza owszem).

Wymaganie 1GB stawiam po prostu dlatego, że to jest połowa mojej obecnej pamięci, więc podchodząc racjonalnie… Nasunął mi się teraz pomysł, żeby to w ogóle sprawdzać, ile mój program bierze pamięci (nawet jeśli miałoby to być jedynie kilka MB).

W opisie nie widze dokladnie co chcesz wyciagnac, ale z przykladu wynika ze po prostu zdanie w ktorym jest odpowedzni cytat.

Tak, ostatecznie stanęło na zdaniu; wcześniej jeszcze myślałem o linijkach, ale zdania są bardziej eleganckie.

Jesli tak, i dla wyrazen jest zbyt skoplikowane zrobienie tego od razu, to latwo bedzie najpierw podzielic tekst na zdania (to mozna zrobic w jednej petli bez wyrazen) i dopiero wyrazenie uruchamiac na pojedynczym zdaniu.

A właśnie, nie pomyślałem, dzięki! I tak wszędzie piszą, żeby nie kombinować, tylko jak najprościej, muszę się jeszcze nauczyć. Próbowałem najpierw znaleźć cytaty, a dopiero potem "dowyszukać" brakujący początek i koniec zdania; spróbuję pokombinować odwrotnie – odfiltrować zdania z cytatami.

Co do samych wyrazen to da sie je napisac (nieco) bardziej czytelnie, np:

		String sentenceRegexp =
			"(?xs)            " +
			"[^\\.\"]*        " +
			"(?: \"[^\"]*\" )*" +
			"[^\\.\"]*        " +
			"\\. 		      "
		;

Właśnie chcę uniknąć takiego dzielenia, bo czasem patrzę na klawiaturę, a nie na ekran, i wtedy mogą mi się jakieś klamry czy cudzysłowy przy przenoszeniu omsknąć lub niepotrzebnie wstawić. W przypadku, gdy samo wyrażenie ma znaki " oraz +, wolę być ostrożny.

To "powiedział", "rzekł", "mówił" jest podatne na bledy. Nawet zakladajac ze wiesz w jakim kodowaniu sa zrodla i jakie jest przekazywane do javac (praktyka pokazuje ze wiekszosc programistow tego nie wie), to z czyms takim sobie nie poradzi: ... tego dnia powiadał: "Dobrze, mój Tadeuszu ...

Nie bardzo rozumiem, dlaczego sobie nie poradzi z tym?

Co do kodowania, staram się wszędzie koncentrować na UTF-8. Na razie mam kontrolę nad źródłem danych. Jeśli przyjdzie do upublicznienia programu (co bym chciał jak najszybciej), to oczywiście zaznaczę w instrukcji, że kodowanie takie a takie być musi, i takie a takie wynikowe będzie.

Co do przekazywania do javac, to nic o tym nie wiem. Coś powinienem?

1

To "powiedział", "rzekł", "mówił" jest podatne na bledy. Nawet zakladajac ze wiesz w jakim kodowaniu sa zrodla i jakie jest przekazywane do javac (praktyka pokazuje ze wiekszosc programistow tego nie wie), to z czyms takim sobie nie poradzi: ... tego dnia powiadał: "Dobrze, mój Tadeuszu ...

Nie bardzo rozumiem, dlaczego sobie nie poradzi z tym?

"powiedział" to nie to samo co "powiadał".
Ogolnie bedziesz mial ciezko zeby kazda sytuacje w ten sposob obsluzyc. np. tu "rzekł" jest po a nie przed cytatem: ... swego roztargnienia: "Prawda" - rzekł - mój Rejencie ...

Co do kodowania, staram się wszędzie koncentrować na UTF-8. Na razie mam kontrolę nad źródłem danych. Jeśli przyjdzie do upublicznienia programu (co bym chciał jak najszybciej), to oczywiście zaznaczę w instrukcji, że kodowanie takie a takie być musi, i takie a takie wynikowe będzie.

Co do przekazywania do javac, to nic o tym nie wiem. Coś powinienem?

Powinienes. Fakt ze jakie to kodowanie to mile. Ale dla javac plik to po prostu jakies blizej niezidentyfikowane byte-y i nawet nie probuje sie domyslac jak zakodowales te byte-y. Mozna zalozyc (no moze nie do konca poprawnie) ze javac kodowanie sobie losuje, wiec jesli teraz program dziala to jest tylko i wylacznie zbieg okolicznosci. Ale:

$ javac -help 2>&1 | grep encoding
  -encoding <encoding>       Specify character encoding used by source files
0
Trzeźwy Kaczor napisał(a):

"powiedział" to nie to samo co "powiadał".
Ogolnie bedziesz mial ciezko zeby kazda sytuacje w ten sposob obsluzyc. np. tu "rzekł" jest po a nie przed cytatem: ... swego roztargnienia: "Prawda" - rzekł - mój Rejencie ...

Ano, ale tym się nie przejmuję… w każdym razie na razie. W wyrażeniach powyżej podzieliłem już możliwe sytuacje na 3 grupy (przed, pomiędzy i za wyrażeniem). W przypadku odwrotnego podejścia pewnie będzie podobnie. Co do wyrazów, to kombinuję, że słownik jest skończony, więc wystarczy, że trochę Pana Tadeusza znam, a obsłużę większość sytuacji. Co do pozostałej mniejszości, kombinuję, żeby najpierw wyfiltrować każdy tekst w cudzysłowie z jakąś liczbą dodatkowych linii przed i po, i cytat po cytacie ręcznie sprawdzić, jaki zwrot jest użyty. Z tej mniejszości zostanie oczywiście jeszcze mniejsza mniejszość – np. sytuacje, że Mickiewicz w ogóle nie pisze, że ktoś mówi, tylko daje sam cytat; lub też daje po prostu inny czasownik, niezwiązany z mówieniem. Jeszcze muszę pomyśleć, jak to obsłużyć. Jest opcja, że użytkownik programu sam zdecyduje, czy chce mieć zbiór okrojony, czy też wszystkie możliwe sytuacje, w których będą false positives.

Oczywiście, Pan Tadeusz to nie wszystkie teksty. Docelowo planuję pozostawić użytkownikowi zdefiniowanie zbioru.

Co do kodowania, staram się wszędzie koncentrować na UTF-8. Na razie mam kontrolę nad źródłem danych. Jeśli przyjdzie do upublicznienia programu (co bym chciał jak najszybciej), to oczywiście zaznaczę w instrukcji, że kodowanie takie a takie być musi, i takie a takie wynikowe będzie.

Co do przekazywania do javac, to nic o tym nie wiem. Coś powinienem?

Powinienes. Fakt ze jakie to kodowanie to mile. Ale dla javac plik to po prostu jakies blizej niezidentyfikowane byte-y i nawet nie probuje sie domyslac jak zakodowales te byte-y. Mozna zalozyc (no moze nie do konca poprawnie) ze javac kodowanie sobie losuje, wiec jesli teraz program dziala to jest tylko i wylacznie zbieg okolicznosci. Ale:

$ javac -help 2>&1 | grep encoding
  -encoding <encoding>       Specify character encoding used by source files

Dzięki za opcję. Co do kodowania, nie jestem w tym biegły (jeszcze). Jeśli widzę, że coś pisze, że obsługuje UTF-8, to na tym poprzestaję. Z losowaniem będę się kłócić. Metoda nio.file.files.write w Javie (której używam obecnie) mówi, że używa UTF-8; na mojej Fedorze locate też zwraca UTF-8 dla wszystkiego poza LC_ALL (puste).

0

Z losowaniem będę się kłócić. Metoda nio.file.files.write w Javie (której używam obecnie) mówi, że używa UTF-8; na mojej Fedorze locate też zwraca UTF-8 dla wszystkiego poza LC_ALL (puste).

To jak zachowuje sie jezyk/narzedzia/... jest opisane w dokumentacji i niedopuszczalne jest wnioskowanie o poprawnosci zachowania wylacznie na podstawie dzialania w konkretnym srodowisku (co nie przeszkadza wielu, moze wiekszosci, programistom programowac w technologii "u mnie dziala"). A w tym konkretnym przypadku w dokumentacji zachowanie jest wyraznie zdefiniowane - kodowanie zalezy od systemu, czyli nie jest znane programiscie jesli programista nie wymusi odpowiedniego zachowania. Gdybys dokladnie ten sam program skompilowal na windows (a uruchomil na tym samym systemie co teraz) to sama zmiana systemu na ktorym jest kompilowany by wystarczyla zeby program przestal dzialac (na chyba ze nowsze windowsy zapomnialy o swoim autorskim kodowaniu - no nie jestem na biezaco).

0

Dobrześ mnie wypunktował. Rzeczywiście, trochę tutaj idę tropem "u mnie działa". Więc pozostaje jeszcze pole do popisu. Lubię wyzwania, jeśli tylko mam na nie czas. Najpierw niech zadziała u mnie, a potem pomyślę albo o wyróżnieniu w instrukcji wszystkich ograniczeń, albo o większej generyczności programu (niestety, najprawdopodobniej nie będę mieć czasu na dłuższe zatrzymanie się na kodowaniu).

Inna sprawa, że co do dokumentacji, to nie wiem, którą masz na myśli. Metoda nio.file.files.write ma wyraźnie napisane (tu), że koduje w UTF-8. Czy są jakieś wyjątki od tego? Czego dokumentacja mówi, że kodowanie pliku zależy od systemu?


Update

Mam pytanie. Napisałem takie wyrażenie, wyłapujące najpierw tekst w cudzysłowach, a następnie do końca zdania po zamykającym cudzysłowie i do początku zdania przed otwierającym cudzysłowem. I to działa: https://regex101.com/r/0pCAcE/1

"(?is)((?<=^)|(?<=\.\s))[^".]+"[^"]+"[^".]*\.((?=\s)|(?=$))"

Ale: dlaczego to działa? Przecież skoro biorę kropkę pod koniec, to drugie dopasowanie od kropki nie może się już zacząć – a regex101 mówi, że właśnie tak się zaczyna. Debugger na regex101 niewiele mi pomaga.

Update 2

Hm, chyba rozumiem… nieistotne, czy wcześniej coś dopasowało, czy nie, liczy się tylko obecna pozycja w wejściowym ciągu znaków. Pozycja zresztą się nie zmienia. Nie znalazłem tego napisanego explicite, ale tak mi to logicznie wynika. Bo przecież wyrażenie regularne nie zapamiętuje dopasowań (dopóki nie użyje się grupy przechwytującej).

1

Inna sprawa, że co do dokumentacji, to nie wiem, którą masz na myśli. Metoda nio.file.files.write ma wyraźnie napisane (tu), że koduje w UTF-8. Czy są jakieś wyjątki od tego? Czego dokumentacja mówi, że kodowanie pliku zależy od systemu?

Wpisujac np. "rzekł" w zrodlach javy i uruchamiajac javac na tych zrodlach obowiazujaca jest dokumentacja javaca - w koncu to on interpretuje te zrodla. A w tej dokumentacji (np: man javac albo https://docs.oracle.com/javase/8/docs/technotes/tools/windows/javac.html): "Sets the source file encoding name, such as EUC-JP and UTF-8. If the -encoding option is not specified, then the platform default converter is used.". I nie jest to jedyne miejsce w javie z takim (wedlug mnie blednym) podejsciem, np. java.io.FileReader operuje wylacznie na domyslym kodowaniu, czyniac ta klase prawie calkowicie bezuzyteczna.
Oczywiscie mozesz nie przejmowac sie kodowaniem i zamiast "rzekł" napisac "rzek\u0142" - wtedy bedzie zdecydowanie mniej czytelne, ale bedzie dzialac (no chyba ze domyslym kodowaniem jest np. UTF16 - ale w takiej sytuacji, dzieki decyzji o losowym kodowaniu w javac, wiekszosc programow wystepujacych w przyrodzie sie nie skompiluje).

0

Dzięki, to cenna informacja dla mnie. Ale w takim razie jak się ma do tego dokumentacja tej funkcji write? Rozumiem, że są możliwe 4 sytuacje:

  1. Wywołuję write bez podania kodowania (czyli domyślnie używa UTF-8), ale kodowanie platformy jest inne niż UTF-8 --> wtedy plik zostanie zakodowany zgodnie z kodowaniem platformy?
  2. Wywołuję write bez podania kodowania (czyli domyślnie używa UTF-8), a kodowanie platformy jest również UTF-8 --> wtedy plik zostanie zakodowany również zgodnie z kodowaniem platformy?
  3. Wywołuję write z podaniem kodowania, ale kodowanie platformy jest inne --> wtedy plik zostanie zakodowany zgodnie z …?
  4. Wywołuję write z podaniem kodowania, a kodowanie platformy jest takie samo --> wtedy plik zostanie zakodowany zgodnie z …?

Dobrze rozumuję?

0

Ale w takim razie jak się ma do tego dokumentacja tej funkcji write?

Nie ma kompletnie zadnego zwiazku.
W kodzie nie pokazales zadnego write(), ale skoro sie do niego odnosisz to zgaduje za gdziesz wykorzystujesz. Wiec zalozmy ze masz kod zrodlowy a w nim napisane np:

Files.writeString(path, "rzekł");

Co tu sie dzieje:

  1. Najpierw ten kod zostal zapisany w na dysk przez edytor tekstowy w kodowaniu kodowanie_1. W tym kodzie wystepuja znaki spoza ASCII wiec programista ma obowiazek znac to kodowanie.
  2. Nastepnie tek kod jest przetwarzany przez javac. Czyli javac intepretuje byte-y przy uzyciu kodowania kodowanie_2, zamienia je na char, a nastepnie to char zapisuje przy uzyciu kodowania kodowanie_3 na dysk w pliku .class. Jesli w kodzie wystepuja znaki spoza ASCII to programista ma obowiazek zapewnic ze kodowanie_1 jest identyczne jak kodowanie_2. Jesli tego nie zrobi to kodowanie_2 bedzie domyslnym systemowym, ktore co najwyzej przypadkiem moze byc takie samo jak kodowanie_1. Kodowaniem kodowanie_3 mozemy sie na razie nie przejmowac - to jest wewnetrzna sprawa javy (zaczac przejmowac sie mozemy jak sie zorientujemy ze w char nijak nie chca sie zmiescic wszystkie znaki unicode).
  3. Przy wykonywaniu kodu java wczytuje plik .class przy uzyciu kodowania kodowanie_3 i zamienia je na char. I tak powstale chary sa przekazywane do Files.writeString, ktore dokonuje kolejnej konwersji z char na byte przy uzyciu kodowania kodowanie_4. Dzieki javadoc Files.writeString wiemy ze kodowanie_4 jest to UTF-8 (jesli programista nie przekaze innego w argumencie).
0

Wielkie dzięki. Będę musiał nad tym pomyśleć.

1 użytkowników online, w tym zalogowanych: 0, gości: 1