fbpx

Mateusz Mazurek – programista z pasją

Czyli o użyciu Pythona i kilku innych technologii do tworzenia świetnej jakości aplikacji w oparciu o stabilny proces dostarczania oprogramowania.

Algorytmika Programowanie

Algorytmy genetyczne w Pythonie

Zagadnienia związane ze sztuczną inteligencją stają się coraz bardziej popularne. Nie dość, że wiele osób o nich mówi, to nieść mogą za sobą worki wypchane pieniędzmi – i nie ma się co dziwić. AI (ang. artificial intelligence) potrafi rozwiązywać problemy z którymi dotąd nie umieliśmy sobie poradzić, a przynajmniej nie w satysfakcjonującym czasie. Ten wpis będzie właśnie o sztucznej inteligencji. Nie będę się jednak skupiał na sztucznych sieciach neuronowych, ale napiszę o podejściu, które moim zdaniem stoi trochę w ich cieniu, a mianowicie o algorytmach genetycznych.

Słowem wstępu

Zacznijmy od tego, czym sztuczna inteligencja jest, a czym nie jest. W programowaniu tradycyjnym piszemy kod, który znajduje rozwiązanie problemu. Przykładowo jeśli chcemy sprawdzić, czy na danym obrazku jest kot, możemy w mniej lub bardziej mądry sposób analizować piksele. W przypadku sztucznej inteligencji nie napiszemy kodu, który będzie analizował obrazek, tylko zdefiniujemy, że dany obrazek przedstawia kota. Wówczas AI „nauczy się” jak wygląda kot, a co za tym idzie, będzie mogło sprawdzić czy na innych obrazach, które mu podamy, również znajdziemy kota. Zauważ proszę że podejście do problemu jest trochę inne – w programowaniu tradycyjnym definiujemy drogę do rozwiązania problemu a w przypadku sztucznej inteligencji – nie definiujemy tej drogi, ale dokładnie ta droga jest wynikiem procesu nauki AI.

Przykład, który wyżej opisałem, to problem klasyfikacji rozmytej, czyli sytuacji kiedy coś „w jakimś procencie jest podobne do wzorca, a więc pewnie nim jest”. Jeśli podamy AI (w przypadku sieci neuronowych) sporą liczbę zdjęć kotów, to nauczymy ją jak „wygląda” kot. Wynikiem tej nauki będą pewne liczby, które na pierwszy rzut oka mówią tyle co nic. I teraz, jeśli użyjemy tych liczb, a więc wiedzy zdobytej na etapie uczenia, ale na INNYM obrazku, to dostaniemy informację w jakim procencie nowy obrazek jest kotem. Czyli czy możemy zaklasyfikować go jako kota.

Trochę jak tu ;)

Przygotowując się do tego artykułu, natknąłem się na podział na słabą i silną sztuczną inteligencję. Słabą, czyli rozwiązującą konkretny problem, opisałem powyżej. Silna to ta do której dążymy, czyli taka, która nie ustępowałaby inteligencji ludzi.

Nim przejdziemy do głównego tematu wpisu, nie mogę odmówić sobie przyjemności, by podzielić się z Wami własnym zdaniem na temat sztucznej inteligencji, mitycznego buntu robotów czy bardziej przyziemnych rzeczy, takich jak kierunek rozwoju oprogramowania czy wpływ na sposób pracy programistów.

Zatem czy sztuczna inteligencja może kiedyś sprawić, że roboty zawładną światem? Bardzo sceptycznie podchodzę do tego tematu. AI jest nadal bardzo „głupiutkie”. Oglądając serię „Z tymi co się znają” natrafiłem na wywiad, w którym dr Aleksandra Przegalińska, nazywa AI „głupolkiem”. I ja się pod tym podpisuję! AI świetnie działa w bardzo wąskich segmentach i nim zacznie być „generyczne” to miną jeszcze długie lata. Jeśli w ogóle do tego dojdzie.

Czy AI zmieni sposób pracy programistów? Sądzę, że w pewnym stopniu tak, ale nie sądzę, by zmiana była drastyczna. Raczej po prostu nasze IDE zacznie być trochę mądrzejsze, podobnie jak statyczna analiza kodu, czy inne podobne elementy, ale core naszej pracy, czyli analiza i implementacja nowych elementów oprogramowania – pozostanie po naszej stronie.

Algorytmy genetyczne

Skoro mamy za sobą już wstęp to czas przejść do sedna tego artykułu.

Algorytm genetyczny to jeden z algorytmów sztucznej inteligencji. Genezą tego pomysłu jest myśl, której autorem jest John Henry Holland, dotycząca tego, jak ewolucja uczy człowieka coraz to lepszego przystosowania się do panujących warunków. Efektem tej refleksji jest właśnie algorytm genetyczny, który jest naprawdę ciekawym narzędziem optymalizacyjnym. Nim przejdę do zagłębienia się w ideę, ustalmy słownictwo.

Słownictwo

Prowadząc Was po labiryntach tego arcyciekawego tematu będę używał słów które są dla niego właściwe i jako dobry przewodnik, zostawiam słowniczek :)

Gen – Najmniejsza część chromosomu.
Chromosom – Uporządkowany ciąg genów.
Genotyp – Zbiór chromosomów.
Osobnik – Najprostsza jednostka podlegająca ewolucji.
Populacja – Zbiór osobników zamieszkujących jedno środowisko.
Krzyżowanie – Losowe przecięcie dwóch chromosomów w jednym punkcie i zamiana podzielonych części między chromosomami.
Mutacja – Nagła zmiana materiału genetycznego.

Algorytm

Wiemy już mniej więcej czym jest Algorytm Genetyczny, znamy słownictwo, więc nie ma co czekać, czas na sam algorytm:

  • Losowana jest pewna populacja początkowa.
  • Populacja poddawana jest ocenie (selekcja). Najlepiej przystosowane osobniki biorą udział w procesie reprodukcji.
  • Genotypy wybranych osobników poddawane są operatorom ewolucyjnym:
    • są ze sobą kojarzone poprzez złączanie genotypów rodziców (krzyżowanie),
    • przeprowadzana jest mutacja, czyli wprowadzenie drobnych losowych zmian.
  • Rodzi się drugie (kolejne) pokolenie. Aby utrzymać stałą liczbę osobników w populacji, te najlepsze (według funkcji oceniającej) są powielane, a najsłabsze usuwane. Jeżeli nie znaleziono dostatecznie dobrego rozwiązania, algorytm powraca do kroku drugiego. W przeciwnym wypadku wybieramy najlepszego osobnika z populacji – jego genotyp to uzyskany wynik.

Proste, prawa?

Skoro tak, to czas na kod.

Piszemy własny mini AI framework

Aby móc ładnie pokazać jak działa Algorytm Genetyczny napiszemy bardzo prosty mini AI framework, który ułatwi nam napisanie właściwego programu. Zaczynamy.

Najpierw utwórzmy klasę reprezentującą osobnika. Będzie ona klasą abstrakcyjną i służyć ma wymuszaniu implementacji kilku metod potrzebnych w procesie działania algorytmu – specyficznych dla danego problemu. Może ona wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from abc import abstractmethod, ABC


class Element(ABC):

    def __init__(self):
        self.fitness = self.evaluate_function()

    def mutation(self):
        self._perform_mutation()
        self.fitness = self.evaluate_function()

    @abstractmethod
    def _perform_mutation(self):
        pass

    @abstractmethod
    def crossover(self, element2: 'Element' ) ->; 'Element':
        pass

    @abstractmethod
    def evaluate_function(self):
        pass

Jak widać wymaga ona implementacji 3 metod:

  • _perform_mutation – funkcja implementująca sposób mutacji. Sposób implementacji mutacji będzie różny dla różnych typów problemów. Przykładowo, jeśli będziemy mieli piksele to będziemy je inaczej traktować niż litery.
  • crossover – funkcja implementująca sposób krzyżowania się osobników, czyli reprodukcji.
  • evaluate_function – funkcja implementująca sposób oceny osobnika. Określa ona jak bardzo jego genotyp jest bliski szukanemu.

Skoro mamy już naszego osobnika, to czas na właściwe przetwarzanie! Możemy to zrobić np. tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class GeneticAlgorithm:

    def __init__(self, first_population_generator: callable,
                 selection_model: callable, stop_condition: callable, mutation_probability: float = 0.1):
        self.first_generation_func = first_population_generator
        self.selection_model = selection_model
        self.stop_condition = stop_condition
        self.mutation_probability = mutation_probability

    def run(self):
        population = self.first_generation_func()
        population.sort(key=lambda x: x.fitness)
        population_len = len(population)
        i = 0
        while True:
            selected = self.selection_model(population)
            new_population = selected.copy()
            while len(new_population) != population_len:
                child = choice(population).crossover(choice(population))
                if random() <= self.mutation_probability:
                    child.mutation()
                new_population.append(child)

            population = new_population
            the_best_match = min(population, key=lambda x: x.fitness)
            print("Generation: {} S: {} fitness: {}".format(i, the_best_match, the_best_match.fitness))
            i += 1
            if self.stop_condition(the_best_match, the_best_match.fitness, i):
                break

Konstruktor przyjmuje:

  • metodę generującą pierwszą populację (tę losową),
  • sposób wybierania najlepszych osobników z populacji,
  • warunek stopu,
  • prawdopodobieństwo mutacji.

Pierwsze trzy elementy są jasne, a co do prawdopodobieństwa mutacji to zgodnie z ewolucją – mutacja jest zjawiskiem rzadkim, ale nie jest nieoczekiwana, wręcz przeciwnie, zgodnie z założeniem jest pewne, że wystąpi, tylko nie wiadomo kiedy. Standardowo przyjmuje się że prawdopodobieństwo mutacji w zadanym kroku wynosi 10%. Mutacja jest elementem który pozwala algorytmowi obrać dobry kierunek na początku, natomiast w trakcie przetwarzania zdarza się że ona przeszkadza. Czasem stosuje się zmniejszanie szansy mutacji wraz z ilością pokoleń.

Najważniejszym elementem tego kodu jest oczywiście funkcja o dźwięcznej nazwie run.

Ten element:

1
2
3
4
5
6
7
            selected = self.selection_model(population)
            new_population = selected.copy()
            while len(new_population) != population_len:
                child = choice(population).crossover(choice(population))
                if random() <= self.mutation_probability:
                    child.mutation()
                new_population.append(child)

odpowiada za:

wybranie z populacji najlepszych osobników (zgodnie ze strategią przekazaną w funkcji selekcji) oraz stworzenie nowej populacji z tych wybranych plus dopełnienie do liczności populacji poprzez krzyżowanie oraz ewentualną mutację :)


Czekaj, stop!

Podoba Ci się to co tworzę? Jeśli tak to zapraszam Cię do zapisania się na newsletter:
a w ramach prezentu otrzymasz całkowicie za darmo, dokument PDF „6 (nie zawsze oczywistych) błędów popełnianych podczas nauki programowania” który jest jednym z efektów ponad siedmioletniej pracy oraz obserwacji rozwoju niejednego programisty.Jeśli to Cię interesuje to zapraszam również na swoje social media.

Czyli jeszcze raz:

  • bierzemy aktualną populację
  • wybieramy z niej najlepszych osobników
  • krzyżujemy ich ze sobą tak długo aż nowa populacja osiągnie liczność równą poprzedniej populacji
  • ewentualnie mutujemy osobnika

Po tej pętli już tylko wybieramy najlepszego osobnika z aktualnej populacji i sprawdzamy czy spełnia warunek stopu.

Sposób selekcji

Z każdego pokolenia wybieramy najlepszych jej przedstawicieli. Sposób w jaki ich wybieramy nazywa się strategią selekcji.

Modelowych sposobów selekcji jest kilka. Sposób w jaki przekazujemy strategię do algorytmu pozwala użytkownikowi na napisanie własnej strategii, ale że nasz framework jest dobrym kawałkiem kodu to wraz z nim dostarczamy implementację jednej z nich. Będzie można sobie ją zaimportować i przekazać po prostu w konstruktorze.

Strategia elitarna

Ta właśnie strategia jest dostępna w naszym frameworku i jej implementacja wygląda tak:

1
2
3
4
def elite_selection_model(generation):
    max_selected = int(len(generation) / 10)
    sorted_by_assess = sorted(generation, key=lambda x: x.fitness)
    return sorted_by_assess[:max_selected]

czyli po prostu wybieramy 10% najlepiej przystosowanych.

Ale istnieje jeszcze kilka innych!

Strategia turniejowa

Dzielimy osobników na grupy i przeprowadzamy „turniej”. Wówczas osobnik, który w danej podgrupie jest najlepszy, przechodzi do kolejnego pokolenia.

Strategia koła ruletki

Dzielimy koło ruletki na wycinki odpowiadające osobnikom w populacji. Wielkość wycinka jest zależna od tego jak bardzo osobnik jest przystosowany – czyli jak dobry jest wynik funkcji przystosowania. Gdy już podzielimy koło ruletki, wybieramy losowo n osobników. Im większy wycinek ma dany osobnik, tym większa szansa że zostanie wylosowany. Ten sam osobnik może być wylosowany więcej niż raz :)

Strategii jest więcej, ale nie ma sensu ich tu wszystkich opisywać.

Przykład

Dobra, czas na coś działającego!

Napiszemy program, który wykorzystując algorytm genetyczny, będzie dążył do stworzenia zadanego tekstu – może brzmi to w tym momencie dziwnie, ale w trakcie się wyjaśni, tak jak reszta elementów algorytmu.

Najpierw będziemy potrzebować kilku importów i tekstu do którego dążymy:

1
2
3
4
5
6
from genetic_library import GeneticAlgorithm, Element
from genetic_library.selection_models import elite_selection_model

from random import randint, choice

TARGET = "Czesc, tu Mateusz z mmazurek.dev :D"

Czyli bierzemy naszą bibliotekę, strategię selekcji, dwie funkcje z random (zaraz okaże się po co) oraz docelowy tekst. Mamy uzyskać zdanie „Czesc, tu Mateusz z mmazurek.dev :D”.

Zacznijmy od najprostszej rzeczy, czyli od warunku stopu:

1
2
def stop_condition(string, current_fitness, i):
    return current_fitness == 0

który sprawdza po prostu czy dobrneliśmy do 100% zgodności.

Dalej tworzymy funkcję generującą pierwsze, losowe pokolenie:

1
2
def first_population_generator():
    return [Text(''.join(choice(Text.POSSIBILITIES) for _ in range(len(TARGET)))) for _ in range(100)]

Tworzymy tu sto obiektów typu Text, przekazując każdemu losowy string o długości równej docelowemu tekstowi. Ten losowy string jest wytworem losowych elementów z pola POSSIBILITIES.

I teraz najważniejszy element – czyli klasa Text, jak się pewnie domyślacie – rozszerzająca klasę Element, którą omówiliśmy wcześniej:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Text(Element):
    POSSIBILITIES = '''abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890, .-;:_!"#%&amp;/()[email protected]${[]}'''

    def __init__(self, text):
        self.text = text
        super().__init__()

    def _perform_mutation(self):
        random_index = randint(0, len(self.text) - 1)
        text_as_list = list(self.text)
        text_as_list[random_index] = choice(self.POSSIBILITIES)
        self.text = "".join(text_as_list)

    def crossover(self, element2: 'Element' ) -> 'Element':
        length = int(randint(0, len(self.text) - 1))
        new_text = self.text[:length] + element2.text[length:]

        return Text(new_text)

    def evaluate_function(self):
        diff = 0
        for letter1, letter2 in zip(self.text, TARGET):
            if letter1 != letter2:
                diff += 1
        return diff

    def __repr__(self):
        return self.text

Oczywiście trzeba tu przyjrzeć się metodom które musimy zaimplementować. Lecimy więc od góry:

1
2
3
4
5
    def _perform_mutation(self):
        random_index = randint(0, len(self.text) - 1)
        text_as_list = list(self.text)
        text_as_list[random_index] = choice(self.POSSIBILITIES)
        self.text = "".join(text_as_list)

Tutaj implementujemy sposób mutacji. Mutacja to nagła zmiana materiału genetycznego. W tym przypadku materiałem genetycznym są litery z określonego zakresu. Więc mutacją takiego osobnika to po prostu zmiana losowej litery na inną losowa literę.

Dalej mamy krzyżowanie:

1
2
3
4
5
    def crossover(self, element2: 'Element' ) -> 'Element':
        length = int(randint(0, len(self.text) - 1))
        new_text = self.text[:length] + element2.text[length:]

        return Text(new_text)

Bierzemy dwa osobniki, w tym przypadku dwa stringi. Losujemy indeks przecięcia się i bierzemy litery od początku do tego indeksu z pierwszego osobnika i łączymy go z literami od drugiego osobnika, wycinając z niego litery od wylosowanego indeksu do końca. Mamy zaimplementowane krzyżowanie!

Następnie ocena przystosowania:

1
2
3
4
5
6
    def evaluate_function(self):
        diff = 0
        for letter1, letter2 in zip(self.text, TARGET):
            if letter1 != letter2:
                diff += 1
        return diff

czyli po prostu liczymy na ilu miejscach aktualny string różni się od docelowego.

Słowo o funkcji oceny

Pisząc ten przykład funkcję oceny miałem na początku zaimplementowaną z użyciem algorytmu levensteina – czyli sposobu na określenie odległości pomiędzy jednym stringiem a drugim. Brzmi idealnie? No prawie…

Algorytm ten okazał się bardzo zawodny i spędziłem sporo czasu zanim odkryłem, że program nigdy nie znajdował rozwiązania zadanego problemu właśnie z powodu niepoprawnej funkcji oceny.

Problem z tym algorytmem polegał na tym że on liczy ile operacji zmiany znaku, usunięcia znaku lub dodania znaku dzieli jeden string od innego. Natomiast My w całym procesie przetwarzania nie mamy możliwości ani dodania znaku ani usunięcia, więc wynik był niemiarodajny. Po prostu ocenialiśmy go nie dając mu możliwości wypaść pod względem tej oceny, dobrze.

Dobre kilka godzin nad tym spędziłem :)

Efekt programu

Przebieg przykładowego uruchomienia programu (po wycięciu pokoleń, które nie wnoszą zmiany) wyglądać może tak:

Generation: 0 S: [email protected]@ [email protected]{zQ(Rm4[zqD)j 1eC5fC fitness: 31
Generation: 5 S: 4,h}cxf ][email protected],Me1#m4[zqD)j 1eC5fC fitness: 30
Generation: 9 S: :76M[K].S [# XILzQ(Rm4[zqD;.6eev:Oe fitness: 29
Generation: 14 S: 4,h}cxf WkoTceQKzQ(Rm4[zqD;.6eev:Oe fitness: 28
Generation: 20 S: UvzJcK].S [# eQKzQ(Rm4[zqD;.6eev:Oe fitness: 27
Generation: 31 S: 4,h}cK .S [WVeQKzQ(Rm4[zqD)jJ-ev:Oe fitness: 26
Generation: 36 S: 47&JcK .S [WreE,zQ(Rm4[zqD)j dev:Oe fitness: 25
Generation: 42 S: 47&JcK .S [WreQKzQzRm4[zqD)j devLOe fitness: 24
Generation: 78 S: 4,hsc? .S nWreQKzQzRm4[zqD)j dev:Oe fitness: 23
Generation: 93 S: C,hsc? .S nWreQKzQzRm4[zqD)j devLOe fitness: 22
Generation: 102 S: C,hsc? .S nWteQKzQzRm4[z/D)j devLOe fitness: 21
Generation: 122 S: C,hsc? .S [WteQszQzRm4[zqD)j devLOe fitness: 20
Generation: 141 S: C,hsc? .S nWteQszVzRm4azqD)j devL=e fitness: 19
Generation: 184 S: C,hsc? .S MWteQszVzRm4azqD)j devLOe fitness: 18
Generation: 212 S: C,hsc? tS MWteQszVzRm4azqD)j devLOe fitness: 17
Generation: 250 S: C,hsc? tS MateGszVzRm4azqD)j devLOe fitness: 16
Generation: 304 S: Czhsc? tS MateGszVzRm4azqD)j devLOe fitness: 15
Generation: 432 S: Czhsc? tS MateGszVz m4azqD)j=devLOe fitness: 14
Generation: 439 S: Czesc? tS Mate szVz m4azqD)j=devLOe fitness: 13
Generation: 456 S: Czesc? tS MateGsz z m4azqD)jddevLOe fitness: 12
Generation: 508 S: Czesc? tS MateGsz z m4azqD)kddevLOe fitness: 11
Generation: 512 S: Czesc? tS MateGsz z mmazqD)kddevLOe fitness: 10
Generation: 518 S: Czesc? tS MateGsz z mmazqr)kddevLOe fitness: 9
Generation: 575 S: Czesc? tS MateGsz z mmazqr)kddev Oe fitness: 8
Generation: 587 S: Czesc? t8 MateGsz z mmazur)kddev Oe fitness: 7
Generation: 594 S: Czesc? tu MateGsz z mmazur)kddev Oe fitness: 6
Generation: 661 S: Czesc? tu MateGsz z mmazurekddev Oe fitness: 5
Generation: 792 S: Czesc? tu Mateusz z mmazurekddev Oe fitness: 4
Generation: 1052 S: Czesc? tu Mateusz z mmazurekddev OD fitness: 3
Generation: 1272 S: Czesc? tu Mateusz z mmazurekddev :D fitness: 2
Generation: 1339 S: Czesc? tu Mateusz z mmazurek.dev :D fitness: 1
Generation: 1497 S: Czesc, tu Mateusz z mmazurek.dev :D fitness: 0

Oczywiście jest on zależny od losowych elementów całego algorytmu, więc każde jego uruchomienie na pewno da inny wynik.

Zalety i wady algorytmów genetycznych

Jak każde rozwiązanie i to nie jest wolne od wad. Jedną z nich jest uniwersalność – i oczywiście mimo że jest ona jednocześnie zaletą, to sprawia, że metoda ta będzie mniej wydajna od wyspecjalizowanych rozwiązań. Dodatkowo ten dział AI jest bardzo wrażliwy na jakość funkcji oceny oraz na sposób zakodowania rozwiązywanego problemu. Oczywiście algorytm genetyczny nie zawsze znajdzie najlepsze rozwiązanie, ale zazwyczaj efekt jego działania będzie satysfakcjonujący.

W zaletach warto wymienić, że algorytm ten jest odporny na na zmiany, które następują w czasie jego działania np. na zmianę funkcję oceny, która może wynikać ze zmiany wariantu który optymalizujemy, albo po prostu ze zmian środowiska, które trzeba w tej funkcji odwzorować.

Ciąg dalszy nastąpi

W ramach tematu Algorytmów Genetycznych pojawi się jeszcze jeden wpis, który będzie po prostu kolejnym przykładem użycia kawałka kodu, który napisaliśmy. Aktualny przykład jak i ten mały framework wrzuciłem na GitHuba – https://github.com/mmazurekdev/genetic-algorithm

EDIT:

Ciąg dalszy nastąpił, zapraszam – https://mmazurek.dev/algorytmy-genetyczne-w-pythonie-ciag-dalszy/

Dzięki za wizytę,
Mateusz Mazurek

A może wolisz nowości na mail?

3
Dodaj komentarz

avatar
2 Wątki
1 Odpowiedzi
1 Śledzący
 
Komentarz z największą liczbą reakcji
Najczęściej komentowany wątek
2 Komentarze autora
Mateusz M.Maciej Michalec Ostatnie komentarze autora

This site uses Akismet to reduce spam. Learn how your comment data is processed.

  Subskrybuj  
Powiadom o
Maciej Michalec
Gość

Dobry wpis, który przyjemnie się czyta. :) Algorytm, który użyłeś do funkcji oceny to po prostu odległość Hamminga. Odległość Levenshteina, o której pisałeś, jest jej uogólnieniem i w Twoim przykładzie zadziała tylko w pewnych (losowych) przypadkach, więc nie dziwię się Twojemu wielogodzinnemu debugowaniu. :D Na dodatek złożoność czasowa jest znacznie gorsza.

trackback

[…] W jednym z ostatnich wpisów omówiliśmy, czym jest algorytm genetyczny, z czego się składa i na czym polega jego działanie. Napisaliśmy również mini framework, ułatwiający pracę z tym rozwiązaniem, aby na końcu, w oparciu o nasz poprzedni kod, stworzyć przykład, który pozwala przekształcić losowy ciąg znaków w zadany tekst. Zrobiliśmy tam naprawdę dużo! Jeśli nie czytałeś poprzedniego wpisu, zachęcam do nadrobienia zaległości, link załączam tutaj. […]

Halo, halo, czekaj chwilę, nie zamykaj!

Super się cieszę że tu jesteś! Bloga tego prowadzę już jakiś czas, uwielbiam pisać i dzielić się wiedzą ale moja pasja do tego jest bez sensu jeśli nie mam czytelników :(

W tej chwili chciałbym Cię zaprosić do zapisania się na newsletter i bycia na bieżąco z tym co przygotowuję. W zamiana za okazane zaufanie dostaniesz dostęp do dokumentu PDF

6(nie zawsze oczywistych) błędów popełnianych podczas nauki programowania”

całkowicie ZA DARMO – wiem, szok i niedowierzanie że daje coś za darmo. Ale ja serio lubię się dzielić wiedzą! Zostaw po prostu swój mail tu – link do zapisania się.