Blogrys

Alicja i Chińczyk polują na słowa

Skrypt do korekty stylu

We wczesnej podstawówce pani Alicja S. przypominała nam cierpliwym, nieugiętym, czerwonym długopisem, że największym wrogiem wypracowań, obok błędów ortograficznych, są powtórzenia wyrazowe z haniebnym “był… był… był…” na czele. Szukajcie zamienników! Albo zmieniajcie układ zdania tak, żeby określenie w ogóle nie musiało pojawić się ponownie! Po dziś dzień starannie sprawdzam pod tym kątem swoje blogowe wypracowania, ponieważ zerwane z łańcucha epifory zaburzają zazwyczaj rytm akapitów1. Zresztą, unikając powtórzeń zmuszamy się do sięgania po metonimie, idiomy lub metafory, tak jak zrobiłem to w poprzednim zdaniu.

Zastępowanie i usuwanie wahadłowych słów jest procesem w najwyższym stopniu kreatywnym. Samo ich wyszukiwanie – to już procedura nadająca się do zautomatyzowania. O dziwo, choć istnieje mnóstwo programów pomagających w identyfikowaniu ortograficznych, gramatycznych i stylistycznych błędów2, nigdy nie natknąłem się na funkcję podkreślania powtórzeń.

Z zamiarem napisania odpowiedniego skryptu w Pythonie nosiłem się od paru lat. Gdy układałem go sobie w głowie, zawsze zastanawiałem się, jak najlepiej będzie określić położenie danego słowa w tekście. W końcu odkryłem, że wystarczy sparsować całość do tablic, przeanalizować i oznaczyć elementy bezpośrednio, a potem złożyć z powrotem w jeden długi string.

W sobotę wieczorem usiadłem do komputera i z pomocą DeepSeeka - chińskiego chatbota wyhodowanego na krwawicy OpenAI, którego przy okazji chciałem przetestować - napisałem w niecałe dwie godziny Alicję. Gdybym bezczelnie kopiował i wklejał kod, zajęłoby mi to znacznie mniej. Mam jednak w zwyczaju wklepywać ręcznie algorytmy wygenerowane przez LLM, żeby je sobie przetrawić i zlokalizować niezrozumiałe lub wątpliwe fragmenty.

Nie mogę powiedzieć, że „efekt przeszedł najśmielsze oczekiwania”, bo oczywiście wiedziałem, iż chatbot stanie na wysokości zadania. DeepSeek zaskoczył mnie mimo to w dwóch momentach3.

Po pierwszej iteracji zapytałem mojego chińskiego asystenta, wielkiego przyjaciela prowincji tajwańskiej, czy spodziewa się jakichś kłopotów w działaniu skryptu. Byłem ciekaw, czy zwróci uwagę na znaczniki nowych linii, które na razie algorytm bezpowrotnie usuwał spawając wejściowe akapity w jedną superlinijkę. Akurat tego konkretnego problemu DeepSeek nie zauważył, ale wymienił szereg innych potencjalnych usprawnień. Wszystkie były rozsądne i inteligentnie opisane, a jeden z nich o kwestię nowych linii w zasadzie zahaczał.

Tak czy owak, po kilku szybkich rundach ulepszyliśmy kod na różne sposoby. Zaopiekowaliśmy się enterami i zignorowaliśmy słowa jednoliterowe4. Umożliwiliśmy też użytkownikowi samodzielne definiowanie słów mających uchodzić za identyczne. Chodziło tu przede wszystkim o postawienie znaku równości między nieszczęsnymi „byłem”, „był”, „była”, itd., zmorą fragmentów opisowych w czasie przeszłym. Poza tym chciałem, żeby dwa długie wyrazy takie same w n pierwszych literach (np. „powinien” i „powinna”), również były oznaczane5.

Ale jak zignorować powtórzenia, które leżą daleko od siebie? „Ponieważ” w drugim ustępie nie będzie w żaden sposób wadziło „ponieważowi” w piątym akapicie6. Zapytałem DeepSeeka, jak najłatwiej, z algorytmicznego punktu widzenia, określić odległość między dwoma słowami: Czy powinniśmy po prostu liczyć znajdujące się pomiędzy nimi wyrazy? A może lepiej liczyć linijki? A może rachować zdania i zaniechać polowania na powtórzenia danego wyrazu po minięciu np. trzech kropek?

DS wskazał pierwszą opcję jako optymalną i udzielił eleganckiego wyjaśnienia: Esensją skryptu jest wszak dzielenie tekstu na słowa, więc i liczenie tychże będzie najłatwiejsze do zrealizowania. Linijki mogą także wejść w rachubę, ale wówczas w kodzie pojawiłaby się dodatkowa warstwa pętli. Natomiast liczenie zdań nie będzie najfortunniejszym rozwiązaniem, ponieważ granice między zdaniami przebiegają wzdłuż różnych znaków przestankowych. Z kolei kropki skrótowe byłyby źródłem błędów alfa.

Przed północą zakończyliśmy sesję.

Oto Alicja7, pierwszy na świecie (guglałem, nie znalazłem, więc pierwszy na świecie) program do wyłapywania powtórzeń wyrazowych w tekście.

Występuje w dwóch wariantach:

  • Wariant hakerski, który pobiera tekst przez stdin i wyrzuca rezultat przez stdout. Sprawdza się idealnie jako terminalowa wtyczka do edytora tekstowego. Jeżeli ni w ząb nie rozumiesz, co właśnie przeczytałeś, to odpowiedniejszy będzie dla Ciebie…
  • wariant lamerski, który pobiera tekst jako plik i zapisuje rezultat do innego pliku.

W kodzie znajdziemy dwie zmienne. n określa, ile pierwszych liter musi się zgadzać, żeby dwa słowa zostały zakwalifikowane jako podobne. Domyślna wartość to 5. Zamieńcie ją na 99, jeśli chcecie wyłączyć tę opcję.

x określa, jak daleko w tekście szukamy powtórzeń. Domyślna wartość to 50 (słów). Zamieńcie ją na 999999, jeśli chcecie szukać powtórzeń w całym tekście8. Na początku kodu znajduje się ponadto fragment, gdzie określamy „rodziny” podobnych wyrazów. Łatwo zrozumieć jego składnię.

Jeżeli nie masz bladego pojęcia jak działa Python, ale chciałbyś wypróbować Alicję, zrób, drogi humanisto, co następuje:

  • Ściągnij wariant lamerski i zapisz go w katalogu ze swoimi tekstami. Muszą to być pliki w czystym TXT. Dokumenty Wordowe nie wchodzą w grę.
  • Zainstaluj Pythona.
  • Otwórz Windowsowy wiersz poleceń (PowerShell, Command Line) w katalogu z tekstami. Czyli: Odszukaj rzeczony katalog w eksploratorze plików, kliknij na niego prawą myszą i z podręcznego menu wybierz „Otwórz w terminalu” albo coś w ten deseń.
  • W wierszu poleceń wpisz teraz komendę python3 alicja2.py mojtekst.txt.
  • Program wygeneruje plik mojtekst_red.txt, gdzie każde powtórzenie oznaczone będzie podwójnym wykrzyknikiem. Teraz musisz tylko przejrzeć lub przeszukać tekst pod kątem , ocenić ciężar powtórzenia i naprawić je lub zignorować.
  • Potem możesz otworzyć alicja2.py prostym edytorem (czyli windowsowym Notatnikiem) i wypróbować inne wartości dla n oraz x.

Piszcie śmiało w komentarzach, jeżeli macie pomysły, jak zwiększyć szybkość algorytmu bądź precyzję działania Alicji. Co do frontendu, zdaję sobie sprawę, że najlepszy byłby prosty GUI, gdzie tekst do sprawdzenia można by wkleić. Jeszcze lepiej, żeby program zaznaczał oddzielne powtórzenia innymi kolorami. Nie mam jednak pojęcia, w jakim frameworku albo bibliotece najłatwiej byłoby coś takiego napisać. Jestem oczywiście otwarty na podpowiedzi.

  1. Zauważmy jednak, że angielski jest na powtórzenia odporniejszy niż polszczyzna. Może Seji, który trudni się przekładami, będzie mógł coś więcej powiedzieć na ten temat? 

  2. A i tak mam tu na myśli wyłącznie staroświecki, deterministyczny software, nie żadne elemele-elelemy. 

  3. Niewykluczone, że inne chatboty wcale nie ustąpiłyby mu tutaj pola. Nie sprawdzałem. 

  4. Pamiętajmy jednak, iż w polszczyźnie za synonim „i” posłuży „oraz”, natomiast sformułowanie „Nie X, a Y” jest rusycyzmem i „a” należy zastąpić w tej konstrukcji spójnikiem „tylko”. 

  5. Prawdopodobnie lepszym rozwiązaniem byłoby mierzenie odległości Hamminga… chociaż z drugiej strony zależało mi na wyszukiwaniu słów o tym samym pniu semantycznym, a nie wszystkich wyrazów podobnie brzmiących. 

  6. Wyjątkiem jest słowo „bardzo”, które według mnie powinno pojawiać sę nie więcej niż raz na artykuł (rozdział). Wyrażenia z „bardzo” mają zazwczaj swoje eleganckie zamienniki. Na przykład „bardzo duży” to przecież „wielki”. 

  7. Przypominam, że w języku polskim nazwy programów komputerów również wyróżniamy kursywą lub ujmujemy w cudzysłów. 

  8. Jeżeli jednak w ogóle nie chcielibyśmy ograniczać zasięgu powtórzeń, należałoby przebudować kod, by uczynić go wydajniejszym. Program powinien w takim wypadku najpierw zliczyć częstość występowania słów, a następnie przejrzeć tekst raz jeszcze od początku i oznaczyć te wyrazy, które zarejestrował wielokrotnie. 






Komentarze

Seji (2025-02-05 23:20:51)

WritingTool dla LibreOffice (i jego protoplasta LanguageTool) wyłapują powtórzenia, od lat. ;) https://writingtool.org/index.php/en/

Borys (2025-02-06 09:54:32)

Po pierwsze, dzięki za wskazówkę. Nie znałem tego rozszerzenia. Pewnie dlatego, że od lat ufam bardziej sobie niż rozbudowanym programom do sprawdzania pisowni. Albo inaczej: Więcej czasu zajmuje mi poprawianie automatycznych poprawek niż sprawdzenie wszystkiego gruntownie samemu. :)

ALE ośmielę się stwierdzić, że Alicja radzi sobie z powtórzeniami lepiej. Bo o ile WT w tym fragmencie

Pies spotkał psa. Potem przyszedł jeszcze jeden pies.

faktycznie wykrywa powtórzenie „pies-psa” w pierwszym zdaniu (co znaczy, że rozumie odmianę wyrazów), o tyle ignoruje zupełnie drugie słowo „pies” w następnym zdaniu.

rozie (2025-02-06 23:16:49)

Nie wiem czy wzrośnie szybkość, ale tak na szybko:
1. wyraz = ''.join(char for char in wszystkie_slowa[i] if char.isalnum()).lower() - czemu nie wyraz = wszystkie_slowa[i].lower()? Chodzi o pozbycie się ew. znaków przestankowych występujących bezpośrednio po wyrazie? Może regex byłby szybszy?

2. pierwsze_litery = wyraz[:N]
if nastepny_wyraz[:N] == pierwsze_litery:
można prościej jako:
if wyraz[:N] == nastepny_wyraz[:N]:

3. oznaczone_slowa nie muszą być listą, prościej użyć set i przechowywać tam tylko numery pozycji oznaczonych słów. Zamiast oznaczone_slowa[i] = True byłoby po prostu oznaczone_slowa.add(i). I wtedy if numer_slowa in oznaczone_slowa: - dodajesz wykrzykniki. To szybka konstrukcja - https://zakr.es/blog/2024/02/dict-set-list/.

A tak w ogóle to przypomniałeś mi tym wpisem, że kiedyś bawiłem się w podobny sposób, tylko chodziło o generowanie propozycji tagów do wpisów na blogu. Też było pomijanie pewnych krótkich słów ze statycznej listy (one mają nawet nazwę specjalną, ale nie pamiętam jej), i próby dopasowywania słów do podobnych, na zasadzie skracania. Coś jak wspólny temat, ale nie listą, tylko algorytmem.

m. (2025-02-08 12:29:28)

Przyglądałem się tematowi już kilka razy i mam takie przemyślenia:
- Trzeba by przeanalizować złożoność algorytmu. Rozumiem, że przechodzimy całość w dwóch pętlach raz analizujemy, raz dekorujemy tekst. Dodatkowo pierwsza pętla ma złożoność kwadratową. Bo szuka słów wyróżnionych do liczenia.
Można optymalizować obydwa obszary, przecież już za drugim zliczeniem i kolejnym można dekorować słowo powtórzone od razu. Wtedy tylko wrócić do pierwszego wystąpienia do dekoracji.
Można także eliminować porównania np. słów na inne pierwsze litery i inne heurystyki jeżeli samo porównanie jest kosztowne.
- No właśnie, ale co jest kosztem? Można wyobrazić sobie operowanie na pliku w pamięci (jakaś wyczesana struktura droga pamięciowo, ale szybsza niż porównanie w pętli). Czyli mamy koszt czasu, pamięci itd.
- A ogólnie to pewnie Python ogólnie jest wolny.

BTW Trudno się czyta plik bez kolorowania - wygodniej wrzucić kod do np. https://codeberg.org/.

rozie (2025-02-08 16:23:25)

Wydaje mi się, że to, że są dwa przebiegi, nie gra większej roli. To nadal O(n) jest. Problemem jest raczej działanie w pierwszej pętli, polegające na sprawdzaniu wszystkich kolejnych słów (skrajnie). To będzie O(n^2) zdaje się.

A tak w ogóle to przydałoby się określić co to jest wolno, jaki czas działania dla jakich danych określamy jako OK, i odpalić profiler.

Borys (2025-02-08 22:20:26)

@rozie
Bardzo sensowne rady, dzięki. Testowałem skrypt na lorem-ipsum składającym się z 10.000 słów. Początkowo potrzebował 0,72 s. Po zastosowaniu Twojej drugiej i trzeciej rady, czas zmalał do 0,70 s. Po zaimplementowaniu rady pierwszej, zmalał do 0,28 s.

Tak, isalnum potrzebny był do czyszczenia tekstu ze znaków przestankowych. Ale okazało się, że lepiej usunąć je za jednym zamachem na początku. I od razu zamienić wszystkie wielkie litery na małe. Myślę, że indywidualne regeksy nie byłyby szybsze. :)

Tagi to rzeczywiście temat na inny post i inny skrypt. Ba, nawet na dyskusję filozoficzną: Co powinno być tagiem? A co nie? Gdzie biegnie granica między tagami a kategoriami? :)


@m
Masz rację, można by dekorować słowa od początku. Ale szkopuł w tym, że po pierwsze, wtedy musiałbym ciągle sprawdzać, czy jakieś słowo nie zostało już oznaczone, i usuwać tymczasowo dekorator w celu porównania słowa z kolejnymi. A po drugie, musiałbym także na bieżąco usuwać znaki przestankowe. Wydaje mi się, że to gra nie warta świeczki: Zastąpiłbym końcówą dekoracyjną pętlę osobnym testem na każde słowo w pętli wyszukującej.


Skrypty na serwerze na razie nie zostały uaktualnione. Zrobię to niebawem.

rozie (2025-02-10 22:20:24)

Dla jasności: to były rady raczej dla czytelności, niż szybkości. Cieszę się, że przyspieszyło. I nie spodziewałem się aż takiej różnicy.

Jeśli chodzi o te tagi, to kategorię wybierałem sam, ręcznie. Skrypt miał wyłącznie pomóc wytypować tagi, na zasadzie prostej analizy częstotliwości "tematów". I kluczowe jest to, że po prostu dostawał cały tekst, wyświetlał małe kilkadziesiąt propozycji z których wybierałem tagi. Kluczowe: "propozycja" i "wybierałem". Bowiem łapałem się na tym, że "normalnie" potrafiłem przeoczyć coś, co mogło być tagiem, a się nim nie stawało. Ale regularnie zdarzały się propozycje, które były wysoko ale nie stawały się tagiem.

Nie sprawdziło się na dłuższą metę, głównie dlatego, że Blox i https://zakr.es/blog/2010/10/tagi-na-blox-jako-przyklad-usability-fail/ WordPress nie ma tych ograniczeń, ale i skrypt gdzieś się zawieruszył, i jakby nie czuję jednak potrzeby.

Boni (2025-03-10 17:26:46)

A propos automatycznego tagowania itp. chciałem do tego kiedyś zaprząc skrypt napędzany WordNetem (https://en.wikipedia.org/wiki/WordNet jest też polska wersja, to wszystko jest darmo rozdawane), bo bardzo wiele rzeczy chciałem zrobić napędzanych WordNetem/Słowosiecią. Ale w międzyczasie mi przeszło.

Borys (2025-03-11 11:56:23)

Mnie moja obsesja też przeszła. O programie, który opisałem w tej notce, marzyłem o wielu lat i podczas testowania skakałem pod sufit ze szczęścia, że działa. Ale teraz w ogóle go nie używam, bo w praktyce takie zmechanizowane poprawianie powtórzeń okazało się przeraźliwie nudne. Więcej frajdy sprawia mi przeglądanie i poprawianie tekstu samemu, nawet jeśli wtedy jakieś powtórki mi uciekają.



C O M E C O N