Implementacja i wykorzystanie wielowarstwowej sieci perceptronowej w modelowaniu makroekonomicznym

Wstecz
Spis treści
Strona domowa

Implementacja sztucznej sieci neuronowej oraz algorytmów uczenia w języku Java

Korzystanie ze sztucznych sieci neuronowych pociąga za sobą wykonywanie ogromnej ilości złożonych i często powtarzających się operacji matematycznych. Nakład obliczeń potrzebny do nauczenia sieci praktycznie wyklucza możliwość rozwiązania problemu za pomocą kartki oraz ołówka i wymusza skorzystanie z narzędzi o potężnych mocach obliczeniowych. Rozwój badań nad systemami neuronowymi i wprowadzenie ich do praktycznych zastosowań było więc silnie związane z pojawianiem się i upowszechnieniem komputerów osobistych oraz efektywnych sposobów ich programowania. Spełnienie wspomnianych warunków przyczyniło się do gwałtownego wzrostu zainteresowania oraz rozkwitu badań nad obliczeniami neuronowymi, który zainicjowany od połowy lat osiemdziesiątych trwa praktycznie do dziś.

Obecnie istnieje wiele wyspecjalizowanych pakietów komputerowych implementujących całą paletę najróżniejszych odmian sieci neuronowych oraz algorytmów ich uczenia. Do szerzej znanych możemy zaliczyć np. Brain, BrainMaker, CascadeCorrelation, Dana, Explorer, Explornet, MathlabToolbox, Nestor, Neudisk, NeuralWorks, NeuroSolution, NuWeb, Propagator, StatisticaNeuralNetworks itd. Obok wymienionych wcześniej istnieją również dziesiątki innych, komercyjnych komputerowych systemów neuronowych oraz niezliczona ilość aplikacji darmowych tworzonych w różnych językach programowania i udostępnianych w Internecie.

Wspomniane pakiety doskonale nadają się do pełnienia roli narzędzi, wszędzie tam gdzie istnieje potrzeba użycia systemów neuronowych bez potrzeby wnikania w szczegóły wykorzystanych algorytmów. Z odmienną sytuacją mamy jednak do czynienia w przypadku, gdy przedmiotem zainteresowania są procesy przetwarzania oraz algorytmy uczenia. Programy stworzone przez innych nie dają wówczas pełnej swobody modyfikacji i testowania interesujących nas szczegółów i właściwości, a jedynym sposobem na pełną dowolność w implementowaniu struktury i algorytmów jest samodzielne oprogramowanie systemu sieci neuronowej oraz metod jej uczenia.

Systemy neuronowe są w istocie matematycznymi symulacjami struktur, traktowanych w literaturze oraz w dyskusjach w konwencji obiektów fizycznych. Pozwala to na ogromną elastyczność w tworzeniu i modyfikowaniu symulowanego systemu, którego jedynymi ograniczeniami są moce obliczeniowe maszyny oraz czas poświęcony przez programistę. Tutaj najprawdopodobniej tkwi źródło niezliczonej ilości zaprezentowanych i opisanych odmian sieci neuronowych i obsługujących je algorytmów. Tym też najpewniej można wyjaśniać silne zdominowanie dziedziny przez podejście praktycznie i w dużym stopniu oderwane od teorii statystycznych, zajmujących się podobnymi lub tymi sami problemami co twórcy sieci neuronowych.

Język Java

Upowszechnienie się idei programowania zorientowanego obiektowo (ang. Object Oriented Programming - OOP) było kolejnym krokiem w celu przyspieszenia oraz usprawnienia procesu tworzenia i rozwoju nowego oprogramowania. Języki zorientowane obiektowo, w odróżnieniu od wcześniej wykorzystywanego podejścia proceduralnego, eliminują ograniczenia jednorodnej przestrzeni nazw oraz umożliwiają projektowanie i implementowanie konkretnych problemów w sposób bliższy ludzkiemu sposobowi rozumowania.

Java jest językiem całkowicie opartym na koncepcji programowania zorientowanego obiektowo. Każda operacja i każdy zbiór danych musi stanowić cześć składową obiektu lub pojęcia obejmującego obiekty tego samego typu czyli klasy. Hermetyzacja pół i metod oraz dziedziczenia i polimorfizm, czyli podstawowe atrybuty obiektowości pozwalają na wielokrotne wykorzystanie kodu, a wbudowane mechanizmy obsługi błędów za pomocą wyjątków, identyfikacji typu w czasie wykonania oraz wielowątkowość, dostarczają dalszych, nowoczesnych i użytecznych możliwości programistycznych. Java jest jednak przede wszystkim językiem programowania sieciowego, należącym do kategorii języków wysokiego poziomu. Przyglądając się jej podstawowym ideom widać wyraźne dążenie twórców do uproszczenia i skrócenia procesu programowania. Cały szereg problemów, związanych ze zwalnianiem zmiennych, wychodzeniem po za rozmiar tablicy, z jakimi spotyka się programista np. C++, zastał przerzucony na mechanizmy wewnętrzne języka. Implementacja większości typowych zadań staje się dzięki temu szybsza i prostsza, ponieważ programista może bardziej skoncentrować się na poszukiwaniu rozwiązania problemu i poświęć mniej uwagi na unikanie niebezpieczeństw wynikających z niuansów języka. Java pozwala jednocześnie zachować całkowitą kontrolę nad kodem, nie posuwając się do uzależnia od środowisk typu RAD (ang. Rapid Application Development) i nie wydzielając programiście miejsc, które mogą podlegać jego nieskrępowanej inwencji twórczej.

Trudno jednak uznać Jave za język optymalny we wszystkich dziedzinach, to co chroni programistę przed popełnianiem błędów jest również ograniczeniem, które nie pozwala zejść na niższy poziom programowania, np. w API (Aplication Programming Interface) systemu operacyjnego. Programy w Javie są niezależne od konkretnej platformy, co zostało osiągnięte przez wprowadzenie elementu pośredniczącego pomiędzy aplikacją a systemem operacyjnym. Tym elementem jest interpreter określany nazwą Wirtualnej Maszyny Javy (ang. Java Virtual Machine). Program wykonywany w ten sposób nie ma szans na osiągniecie wydajności programów skompilowanych do postaci kodu maszynowego właściwego dla konkretnej platformy. Wspomniane cechy, będące źródłem doskonałych właściwości sieciowych Javy, sprawiają jednocześnie że przegrywa ona na wielu polach z językami takimi jak C++, pozwalającym na programowanie względnie niskopoziomowe, dużą kontrolę nad kodem i pozbawionymi elementu interpretera.

Implementacja sieci neuronowej oraz algorytmów jej uczenia jest zagadnieniem, które z powodzeniem może zostać oprogramowane w sposób wykorzystujący standardowe rozwiązania, dostarczane przez mechanizmy języka Java. Drugim silnym atutem skłaniającym do wykorzystania Javy są jej ogromne możliwości sieciowe, które otwierają interesujące perspektywy zarówno dla prezentacji w Internecie, jak i skorzystania z zalet programowania rozproszonego. Do wspomnianych zalet należy również używanie przez Jave międzynarodowego, 16-bitowego standardu znakowego o nazwie Unikod. Stosując Unikod mamy gwarancje, że po zapisaniu dowolnego znaku na jednym systemie operacyjnym, na innych platformach uzyskamy dokładnie taki sam znak.

Właściwości wynikające z programowania sieciowego i rozproszonego, połączone z możliwością wielokrotnego wykorzystania już istniejących klas, pozwalają względnie łatwo obudować implementacje obliczeń neuronowych do postaci potężnych sieciowych systemów informatycznych. Użycie apletu do prezentacji działania już nauczonej sieci eliminuje konieczność instalowania oprogramowania , a użytkownik ma jednocześnie pewność że aplikacja nie może wyrządzić żadnych szkód na jego komputerze. Wykorzystanie modelu klient-serwer, np. do zdalnego uczenia i przetwarzania danych przez system neuronowy, również nie stanowi większego problemu w Javie. Wiele zadań, takich jak np. optymalizacja struktury dużych sieci neuronowych za pomocą algorytmów genetycznych, wymaga potężnych mocy obliczeniowych. Doskonałym rozwiązaniem może być w takiej sytuacji zastosowanie koncepcji masowych obliczeń równoległych, wymagających także języka o dużych możliwościach programowania sieciowego i posiadających już przykłady implementacji w Javie.

Wzorce projektowe w Javie

Podczas tworzenia aplikacji programista rozwiązuje szereg konkretnych problemów, z których duża cześć jest podobna lub nawet taka sama dla większości programów komputerowych. Mamy wiec do czynienia z pewnymi powtarzającymi się rozwiązaniami, które doskonale nadają się do ujęcia w bardziej ogólne wzorce, łączące w sobie najlepsze ze znanych rozwiązań i tworzące rodzaj modelowej implementacji konkretnego problemu. Potrzeba opracowania takich schematów jest tym bardziej uzasadniona w odniesieniu do programowania zorientowanego obiektowego, które z założenia ma umożliwić wielokrotne wykorzystanie kodu oraz operowanie na klasach i obiektach.

Wymienione powody legły u podstaw upowszechnienia się koncepcji i pojęcia wzorca projektowego (ang. design pattern), omówionej w fundamentalnej pozycji autorstwa tzw. Gangu Czworga (ang. Gang of Four) pt. Wzorce projektowe. Pojęcie wzorca projektowego opisuje strukturę bardziej ogólną od klasy i odnosi się do opisu strategii wzajemnych zależności i zasad komunikacji pomiędzy obiektami. Zgodnie z jedną z popularniejszych definicji:

"Wzorce projektowe stanowią powtarzalne rozwiązanie zagadnień projektowych, z którymi się wciąż spotykamy".

Wprowadzenie wzorców projektowych jest najefektywniejszym rozwiązaniem w sytuacji gdy jeden i ten sam problem musi rozwiązywać wielu programistów w różnych projektach. Wymyślanie rozwiązania przez każdego z nich osobno i od początku, nie tylko wydłuża proces tworzenia, ale praktycznie zawsze prowadzi do rozwiązania mniej optymalnego. Skorzystanie ze wzorca, będącego dopracowanym efektem pomysłów wielu programistów, nawet jako punktu wyjścia do dalszych prac, skraca czas tworzenia i podnosi jakość efektu końcowego. Rozpowszechnianie się wzorców oznacza jednocześnie łatwość w korzystaniu z cudzych fragmentów kodu, które o ile wpisują się w ramy znanych schematów, nie stanowią już przedmiotu do zgłębiania, ale standardowe cegiełki do sprawnego budowania większej całości. Wzorce to wreszcie rozwój fachowej terminologii, która umożliwia zwięzłą i precyzyjną komunikację oraz tak samo lakoniczne i jednoznaczne opisy.

Oczywiście wszystkie wymienione zalety zależą w dużej mierze od rozwoju i upowszechniania się wzorców, zarówno jako koncepcji ogólnych, jak i w postaci przykładów implementacji w konkretnych językach obiektowych. Java jako język czysto obiektowy również podlega temu procesowi i dlatego rozwój oraz wykorzystywanie wzorców projektowych w tym języku są dobrze uzasadnione efektywnością programowania i korzyściami wynikającymi z wielokrotnego wykorzystania kodu.

Wzorce projektowe dzieli się na trzy grupy:

Koncepcja silnika neuronowego

Punktem wyjścia przy implementacji sieci neuronowej i algorytmów uczenia było dążenia do zbudowania jak najbardziej uniwersalnej aplikacji, pozwalającej przy wykorzystaniu parametrów oraz systemu plików przeprowadzać obliczania oraz trening na możliwie szerokiej palecie dowolnych, jednokierunkowych, wielowarstwowych sieci neuronowych z sigmoidalną funkcją aktywacji neuronu. Główna idea projektu koncentruje się na stworzeniu oprogramowania obejmującego przede wszystkim istotę obliczeń neuronowych, będącego swego rodzaju silnikiem, który z powodzeniem można wykorzystać samodzielnie lub łatwo włączyć do współpracy z innym aplikacjami.

Omawiany program komputerowy, nazwany roboczo NeuralEngine, został w całości napisany w języku Java, przy użyciu API zgodnego ze środowiskiem wykonywania J2SE v1.2.2 (ang. Java 2 Platform, Standard Edition, version 1.2.2) lub późniejszym. W celu maksymalnego rozszerzenia możliwości wielokrotnego wykorzystania kodu oraz ułatwienia konserwacji i rozwijania programu, jego struktura ogólna została ujęta, wszędzie tam gdzie było to uzasadnione, w postaci standardowych wzorców projektowych. Obok dążenia do elastyczności na poziomie kodu, program koncentruje się na potencjalnym umożliwieniu współpracy z oddzielnie tworzonymi aplikacjami na poziomie funkcjonalności systemu operacyjnego.

Istnieją trzy sposoby komunikowania się z aplikacją. Wszystkie korzystają z opracowanego na potrzeby obliczeń neuronowych zbioru komend, określających rodzaj żądanej operacji oraz parametry jej wykonania. Program nie posiada wpisanej na stałe do kodu struktury sieci neuronowej, ale korzysta z plików tekstowych o ustalonym formacie. Podobnie został rozwiązany problem budowy sieci, obserwacji i danych wejściowych. Wszędzie tam, gdzie jest to wymagane, informacje również są wczytywane z plików tekstowych o zdefiniowanej strukturze. Takie same rodzaje plików jak w przypadku danych uczących służą do zapisywania efektów wykonywanych obliczeń, a wszystkie wymienione informacje przekazywane są do aplikacji jako nazwy odpowiednich plików w postaci parametrów wywoływanych poleceń.

Pierwszą metodą pracy z aplikacją jest wpisanie odpowiedniej komendy bezpośrednio w wierszu poleceń. Wyniki operacji zawsze zapisywane są do wskazanych plików oraz wysyłane na standardowe wyjście, czyli w tym przypadku na konsolę. Drugim o wiele bardziej efektywnym sposobem wykonywania nawet całej serii operacji jest wpisanie polecenia wczytania i wykonania pliku tekstowego z zestawem komend. Komendy w skrypcie muszą byś zakończone średnikiem (;), a sam tekst może zawierać znane np. z języków C, C++ lub Java komentarze jednowierszowe, zaczynające się od znaków "//", a wygasające z końcem wiersza.

Przykład 4.1. Plik z poleceniami programu NeuralEngine v1.0

randomize weights using uniform distribution
    for network weights_8_6_4_0.txt
    send result into weights_8_6_4_0.txt
    with min -0.5 // komentarz jednowierszowy
    max 0.5;

train using mebp incremental
    for network weights_8_6_4_0.txt
    data data.txt
    send result into mebp_incremental_weights_8_6_4_0.txt;

output for
    network mebp_incremental_weights_8_6_4_0.txt
    data data.txt
    send result into data_result.txt;

Włączenie do programu możliwości przetwarzania wsadowego opartego na plikach z poleceniami pozwala skrócić do minimum interakcję z interfejsem. Użytkownik może wykorzystać gotowe schematy poleceń, a następnie za pomocą jednej krótkiej komendy execute nazwaPliku błyskawicznie zlecić aplikacji wykonanie całego szeregu skomplikowanych operacji.

Wykorzystywanie takich rozwiązań w dobie popularności GUI (ang. Graphical User Interface), czyli popularnych okienek i formularzy, może wydawać się nieco archaiczne i skomplikowane. Jednak praktyka użytkowania wykraczająca poza chwilowy kontakt z aplikacją wskazuję na ogromną przewagę przetwarzania wsadowego. Łatwo wyobrazić sobie korzystanie z odpowiednio dopasowanego do problemu interfejsu GUI przy trenowaniu sieci. Przede wszystkim należałoby wówczas otworzyć minimum dwa pliki, jeden zawierający strukturę sieci oraz drugi z obserwacjami, a w przypadku algorytmu wczesnego stopu w grę wchodziłoby nawet więcej plików. Dodatkowo należałoby wskazać ścieżkę zapisu wyników oraz wpisać w odpowiednie miejsca formularza pozostałe parametry liczbowe algorytmu. Liczba operacji jakie trzeba wykonać na oknach GUI jest całkiem spora, a w przypadku całej sekwencji podobnych lub powiązanych poleceń, staje się ona długą i nużącą rutyną, którą można zastąpić wpisaniem w konsoli jednego krótkiego polecenia.

Mimo powyższych argumentów, brak interfejsu graficznego jest znaczącym ograniczeniem, które jednak można łatwo wyeliminować wykorzystując możliwości trzeciego sposobu komunikacji z aplikacją. Każda komenda, którą wpisuje się w wierszu poleceń może być także parametrem wykonania programu. W takim przypadku system wykonuje przekazane polecenie, po czym kończy działanie pozostawiając wyniki we wskazanych w komendzie plikach.

Przykład 4.2 Wywołanie aplikacji NeuralEngine v1.0 w formie pliku jar z komendą w postaci parametru

java -jar NeuralEngine10.jar error for network aebp_weights_8_6_4_0.txt data data.txt send result into error.txt

Można wiec wyobrazić sobie program GUI, będący nakładką graficzną do silnika neuronowego i korzystający z wywoływania tej drugiej aplikacji z parametrami, lub przechwytujący jej standardowe wejście i wyjścia. Rozwiązanie takie można posunąć jeszcze dalej i wykorzystując doskonałe możliwości sieciowe Javy, przebudować silnik neuronowy także do postaci serwera. W tym momencie mamy do czynienia ze strukturą aplikacji rozproszonej, której dodatkową zaletą jest niezależność poszczególnych elementów od siebie. Części składowe systemu mogą być napisane w różnych językach programowania i modyfikowane oraz zamieniane bez konieczności korzystania z kodu lub nawet kompilacji drugiego programu. Jako przykład takiego oprogramowania można zaprezentować system Ghostscript, będący konsolową przeglądarką plików PostScript, PDF i innych. Ghostscript może działać samodzielnie lub zostać rozszerzony o nakładkę graficzną o nazwie GSview, będącą osobną aplikacją i podlegającą osobnej instalacji.

Interpreter poleceń i koncepcja łańcucha odpowiedzialności

Interpreter poleceń jest obok pakietu klas implementujących obliczenia neuronowe główną częścią prezentowanego programu. Wzorzec interpretera stosowany jest przede wszystkim w zadaniach wymagających przetwarzania równań matematycznych, generowania zróżnicowanych i trudnych do ujednolicenia wyników oraz, tak jak w poniższym przykładzie, przy konieczności wykonywania podobnych lub powtarzających się poleceń użytkownika. Interfejs takiego wzorca musi obejmować funkcje przeprowadzające: rozbiór komendy na części składowe, wyodrębnienie parametrów, utworzenie odpowiedniego obiektu implementującego wzorzec polecenia i wywołanie metody rozpoczynającej jego wykonanie.

Przykład 4.3. Interfejs interpretera

public interface Interpreter {
/*--------------------------------------------------------*/
	public void interpret(String text);
/*--------------------------------------------------------*/
	public void interpret(String[] parameters);
}

W powyższym przykładzie występują dwie funkcję różniące się od siebie rodzajem parametru. Do pierwszej z nich przekazywany jest łańcuch tekstowy, co jest naturalne podczas interpretacji poleceń tekstowych, a do drugiej tablica parametrów tekstowych, co z kolei może być wygodniejsze przy bardziej zaawansowanym procesie rozbioru komendy.

Interpretowanie przebiega w nieco odmienny sposób w przypadku wykorzystywania funkcji i poleceń. Różnic jest wiele, od kwestii wartości zwracanej do sposobu grupowania i oddzielania parametrów, a wszystkie one skłaniają do utworzenia pierwszej klasy w hierarchii dziedziczenia wyspecjalizowanej w tym wypadku pod kątem poleceń.

Przykład 4.4. Abstrakcyjna klasa bazowa interpretera poleceń

public abstract class CommandInterpreter implements Interpreter {
/*--------------------------------------------------------*/
	public void interpret(String text) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public abstract void interpret(String[] parameters);
/*--------------------------------------------------------*/
	protected static String[] removeDecoration(String[] parameters, String[] decoration) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	protected static String[] removeFirst(String[] parameters) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	protected static Map groupArguments(String[] parameters, String[] names) {
		// detailed operations
	}
}

Interpretowanie rozpoczyna się w funkcji public void interpret(String text) od przekształcenia łańcucha tekstowego na tablicę słów oddzielonych w pierwotnym poleceniu tzw. ogranicznikami (ang. delimiters), czyli spacją, tabulatorem lub znakiem nowego wiersza. Klasa dostarcza ponadto zbiór funkcji statycznych, implementujących typowe operacje jakie będą wykonywane w klasach konkretnych, a związane z: usuwaniem słów nie mających znaczenia w poleceniu, czyli pełniących jedynie funkcję ozdobnika - protected static String[] removeDecoration(String[] parameters, String[] decoration), usuwaniem pierwszego członu polecenia - protected static String[] removeFirst(String[] parameters) i grupowaniem parametrów niezbędnych do utworzenia obiektu implementującego polecenie - protected static Map groupArguments(String[] parameters, String[] names). Wymienione funkcje muszą mieć formę statyczną, ponieważ klasy konkretnych interpreterów posiadaj własne, wspólne dla całego polecenia statyczne tablice przekazywane do nich w formie parametrów. We wspomnianych tablicach przechowywane są wzory nazw argumentów komendy oraz wzory słów pełniących funkcję ozdobnika. Jedyną funkcją jaką należy zaimplementować w konkretnych klasach interpreterów jest public abstract void interpret(String[] parameters).

Główna idea interpretowania odwołuje się do wzorca o nazwie łańcuch odpowiedzialności (ang. chain of responsibility). Jego działanie sprowadza się do umożliwienia pewnej liczbie połączonych ze sobą łańcuchowo obiektów podjęcia próby obsłużenia żądania. Żadna z klas nie wie o możliwościach innych i każda próbuje samodzielnie obsłużyć żądanie. W przypadku gdy klasa nie jest w stanie w pełni obsłużyć żądania przekazuje je do następnej i cały proces trwa tak długo aż komunikat zostanie obsłużony. Podobnie w przypadku omawianego systemu tekst polecenia przekazywany jest najpierw do ogólnego interpretera aplikacji. Tutaj na podstawie pierwszego wyrazu rozpoznawany jest rodzaj komendy, następnie pobierany jest interpreter właściwy dla tego rodzaju, po czym komenda pozbawiona zidentyfikowanego słowa przekazywana jest do owego interpretera. Cały proces odbywa się na strukturze przypominającej drzewo. Interpretacja pozwala uszczegółowić rodzaj komendy, po czym na najniższym poziomie następuje pogrupowanie argumentów i przekazanie przetwarzania do właściwego obiektu wykonującego polecenie.

Cały proces tworzenia i pobierania klasy odpowiedniego interpretera opiera się na zawartości statycznego pola z obiektem implementującym wzorzec fabryki. Omawiane pole ma charakter statyczny, ponieważ przechowuje wspólną dla całej klasy informację o interpreterach bardziej szczegółowych, dostępnych dla określonego poziomu rozbioru komendy. Oczywiście klasy znajdujące się na dole hierarchii i zajmujące się tworzeniem obiektów wykonujących polecenia nie posiadają takiego pola. Od obiektu fabryki przypisanego do statycznego pola interpretera zależy jakie komendy są obsługiwane przez aplikację. Zamiana domyślnej fabryk i na inną oznacza zmianę palety obsługiwanych komend. Rozmiar modyfikacji zbioru poleceń zależy od poziomu na jakim podmieniamy obiekt fabryki. W przypadku głównego interpretera programu, wymianie podlega cały zestaw poleceń, a w przypadku interpreterów bardziej szczegółowych jedynie pewna rodzina komend.

Przykład 4.5 Główny interpreter programu razem z funkcją umożliwiającą modyfikację całego zbioru poleceń przez zmianę obiektu fabryki

public class NeuralEngineInterpreter extends CommandInterpreter {
/*--------------------------------------------------------*/
	private static Factory factory = new NeuralEngineFactory();
/*--------------------------------------------------------*/
	public static void setFactory(Factory factory) {
		
		if(factory != null) {
			NeuralEngineInterpreter.factory = factory;
		}
	}
/*--------------------------------------------------------*/
	public void interpret(String[] parameters) {
		// detailed operations
	}
}

Wzorzec fabryki i metody fabrycznej użyty w interpreterze

Wzorzec projektowy fabryki może być implementowany praktycznie w każdym programie napisanym w języku zorientowanym obiektowo. Występuje on w trzech odmianach, a jego podstawową formą jest tzw., fabryka prosta (ang. simple factory), której działanie sprowadza się do zwracania instancji klasy odpowiadającej informacjom przekazanym w żądaniu skierowanym do fabryki. Zwracane obiekty wywodzą się od wspólnej klasy bazowej, dzięki czemu jedna i ta sama metoda, po dokonaniu rzutowania w górę hierarchii, może zwracać instancje różnych typów. Zaletą stosowania każdej z odmian fabryki jest uniezależnianie kodu klienta od konkretnych klas, na których wspomniany kod wykonuje operacje. Pociąga to za sobą zwiększenie hermetyzacji obiektów i ułatwia konserwacje oraz modyfikację kodu zwracanych instancji. Klient korzystający z dostarczanych przez fabrykę obiektów musi znać jedynie identyfikator potrzebnej mu klasy, w oparciu o który obiekt fabryki zwraca odpowiednią instancję. Dzięki temu ewentualne zmiany w typach już przypisanych do odpowiednich modyfikatorów lub dodanie nowej palety dostępnych klas, oznaczają jedynie modyfikowanie kodu fabryki, podczas gdy korzystający z niej kod klienta może dalej poprawnie działać, bez jakiejkolwiek ingerencji programisty.

Rozwinięciem fabryki prostej jest tzw. fabryka abstrakcyjna (ang. abstract factory). Klasy fabryki abstrakcyjnej posiadają wspólny interfejs i obsługują takie same identyfikatory typów zwracanych instancji. Każda z nich jednak odnosi się do innej grupy klas i w odpowiedzi na ten sam identyfikator może zwracać obiekt klasy o podobnych właściwościach, ale należący do innej grupy. Najczęściej elementem składowym wzorca fabryki abstrakcyjnej jest instancja implementująca fabrykę prostą, która służy do pozyskiwania obiektu konkretnej odmiany fabryki, związanej z pożądaną grupą klas.

Przykład 4.6. Wspólny interfejs fabryki prostej oraz fabryki abstrakcyjnej

public interface Factory {
/*--------------------------------------------------------*/
	public Object get(int id);
/*--------------------------------------------------------*/
	public Object get(String id);
}

W podanym przykładzie wykorzystuje się przeciążoną funkcję get zwracającą instancję rzutowaną do bazowej klasy wszystkich klas w języku Java i przyjmującą jako parametr identyfikatora liczbę całkowitą lub łańcuch znakowy.

Innego interfejsu wymaga trzecia odmiana wzorca projektowego fabryki, określana nazwą metody fabrycznej (ang. factory method). W przypadku metody fabrycznej, w odróżnieniu od wcześniej omawianych koncepcji, nie istnieje jedna centralna klasa decyzyjna, określająca jakiego typu powinien być zwracany obiekt. Decyzja o typie dostarczanego obiektu delegowana jest do klas pochodnych, które nie korzystają z identyfikatora, ale zwracają instancję klasy odpowiadającą kontekstowi w którym operują. Dzięki takiemu rozwiązaniu implementacja wzorca metody fabrycznej może być łatwo rozbudowana i dołączana wszędzie tam, gdzie zachodzi potrzeba dostarczenia obiektu o typie odpowiadającym klasom aktualnie wykorzystywanych instancji.

Przykład 4.6. Interfejs metody fabrycznej

public interface FactoryMethod {
/*--------------------------------------------------------*/
	public Object get();
}

Właściwości metody fabrycznej można wykorzystać przy implementacji wzorca fabryki prostej. Fabryki interpreterów wykorzystywane w omawianej aplikacji neuronowej korzystają z klasy kontenerowej HashMap, przechowującej instancje anonimowych klas wewnętrznych, implementujących metodę fabryczną w oparciu o zaprezentowany wyżej interfejs.

Klasa kontenerowa HashMap jest przykładem odwzorowania, określanego także jako słownik lub tablica asocjacyjna. Jej metoda składowa public Object put(Object key, Object value) dodaje do kontenera obiekt dowolnego typu razem z odpowiadającym mu kluczem, służącym do odszukiwania obiektu. Metoda public Object get(Object key) pozwala z kolei na pobranie obiektu, odpowiadającego przekazywanej w parametrze wartości klucza.

Przykład 4.7 Implementacja fabryki interpreterów dla głównego interpretera aplikacji

public class NeuralEngineFactory implements Factory {
/*--------------------------------------------------------*/
	private static final HashMap map = new HashMap();
/*--------------------------------------------------------*/
	static {
		
		FactoryMethod method;
		
		method = new FactoryMethod() {
			public Object get() {
				return new ErrorInterpreter();
			}
		};
		NeuralEngineFactory.map.put("error", method);
		
		method = new FactoryMethod() {
			public Object get() {
				return new ExitInterpreter();
			}
		};
		NeuralEngineFactory.map.put("exit", method);
		
		// detailed operations
	}
/*--------------------------------------------------------*/
	public static void register(String id, FactoryMethod method) {
		NeuralEngineFactory.map.put(id, method);
	}
/*--------------------------------------------------------*/
	public Object get(int id) {
		throw new UnsupportedOperationException();
	}
/*--------------------------------------------------------*/
	public Object get(String id) {
		
		FactoryMethod method;
		
		if(NeuralEngineFactory.map.containsKey(id)) {
			method = (FactoryMethod)NeuralEngineFactory.map.get(id);
			return method.get();
		}
		else return null;
	}
}

Obiekt kontenera typu HashMap, jako wspólny dla wszystkich obiektów interpreterów, przechowywany jest w polu statycznym klasy. W sekcji inicjalizacji statycznej do kontenera dodawane są jako klucze identyfikatory klas oraz jako wartości instancje anonimowych klas wewnętrznych, zwracające na żądanie obiekty typu określonego przez identyfikator. Statyczna funkcja public static void register(String id, FactoryMethod method) dostarcza prostego sposobu na dodanie nowych instancji klas pochodnych bazowej klasy metody fabrycznej razem z identyfikatorami. Wspomnianą funkcję można wykorzystać np. w sekcji statycznej klasy dziedziczącej z tej, która została przedstawiona w przykładzie 4.7. Metoda get dla argumentu całkowitego nie jest w powyższym przykładzie obsługiwana i dlatego jej wywołanie powoduje wyrzucenia wyjątku UnsupportedOperationException(), oznaczającego w API Javy brak implementacji dla danej operacji.

Analiza działania zarówno klas interpreterów jak i fabryk interpreterów może skłaniać do wniosku, że struktura wewnętrzna klas pozwala na ograniczenie się jednie do pól i metod statycznych. Klasy te nie przechowują informacji właściwej dla pojedynczych obiektów, a wszelkie niezbędne parametry przyjmują w postaci argumentów odpowiednich funkcji. Przyjęcie jednak takiego rozwiązania uniemożliwiłoby wykorzystanie powyższej implementacji interpreterów i koncepcji fabryk, które zwracając konkretne obiekty interpretujące w postaci rzutowanej do typu bazowego, korzystają z polimorficznego wywołania metod oraz same są obiektami przypisanymi do pól interpreterów.

Wzorzec polecenia jako rozdzielnie operacji interpretowania, obsługi interfejsu i obliczeń neuronowych

Interpretowanie polecenia kończy się w omawianym programie stworzeniem instancji klasy obejmującej wszystkie operacje, jakie wiążą się z wprowadzoną komendą. Jest to typowe zadanie dla wzorca projektowego polecenia (ang. command), które zamyka w sobie pewien zestaw działań i pozwala na jego wykonanie za pośrednictwem wspólnego, publicznego interfejsu. Zaletą takiego podejścia jest możliwość zgłaszania przez klienta żądań, bez wiedzy na temat operacji jakie zostaną wykonane, a posługiwanie się wspólnym interfejsem pozwala na modyfikowanie zestawu owych operacji bez konieczności ingerowania w kod po stronie klienta.

Przykład 4.8. Wspólny, publiczny interfejs polecenia

public interface Command {
/*--------------------------------------------------------*/
	public void execute();
}

Najlepszym przykładem zastosowania wzorca polecenia jest sterowany zdarzeniami model obsługi graficznego interfejsu użytkownika. W omawianej aplikacji klasy poleceń służą przede wszystkim do logicznego i wygodnego grupowania kodu realizującego różne typy operacji. Konkretne typy poleceń wymagają często podczas procesu konstrukcji podania zestawu argumentów, a cały szereg dodatkowych metod typu set ustawia dodatkowe, opcjonalne parametry wykonania komendy. W takiej sytuacji obiekt interpretera musi wiedzieć jakiego typu polecenie powinien stworzyć, dodatkowo sparametryzować i wykonać. Operacja wykonania jest w pewnym sensie przedłużeniem operacji interpretowania, ale rozdzielenie przetwarzania pomiędzy dwie różne klasy ułatwia późniejszą modyfikację i lepiej systematyzuje program.

Przykład 4.8. Przykład polecenia losującego startowe wagi sieci neuronowej

public class RandomizeUniformCommand implements Command {
/*--------------------------------------------------------*/
	private String networkFile, resultFile;
	private double min = -0.5, max = 0.5;
/*--------------------------------------------------------*/
	public RandomizeUniformCommand(String networkFile, String resultFile){
		// detailed operations
	}
/*--------------------------------------------------------*/
 	public void setMin(double min) {
		this.min = min;
 	}
/*--------------------------------------------------------*/
	public void setMax(double max) {
		this.max = max;
	}
/*--------------------------------------------------------*/
	public void execute() {
		// detailed operations
	}
}

Obiekt polecenia jest praktycznie jedynym miejscem w programie, gdzie dochodzi do wykonywania operacji wejścia wyjścia. Jedyną interakcją z użytkownikiem wykraczającą poza klasy poleceń są komunikaty o błędach, zgłaszane przez interpretery. Komunikaty te są jednak wysyłane na standardowe wyjście błędów, co w przypadku poważniejszej modyfikacji systemu daje względnie duże możliwości przekierowania strumienia. Poważniejsza przebudowa, taka jak np. zmiana interfejsu aplikacji, oznacza głównie wymianę lub modyfikację klas poleceń. Taka architektura systemu wyodrębnia trzy logiczne całości kodu w postaci warstwy klas wykonujących interpretację polecenia, zbioru klas zajmujących się obsługą wykonania komendy pod kątem interakcji z użytkownikiem i systemem plików oraz poziomu właściwych obliczeń neuronowych, których kod jest całkowicie oddzielony od metody sterowania i rodzaju interfejsu aplikacji.

Klasy obsługujące wejście i wyjście jako dekoratory

Najprostszym sposobem dodawania nowej funkcjonalności do klasy jest rozbudowa hierarchii dziedziczenia o nową klasę pochodną. Czasami jednak zachodzi potrzeba dodawania całej palety nowych operacji, które dodatkowo powinny występować w wielu kombinacjach, czasami trudnych nawet do przewidzenia. Implementowanie każdej możliwej kombinacji nowych działań w postaci odrębnych klas oznacza tworzenie dużym nakładem pracy bardzo rozbudowanej i niejasnej hierarchii klas, w której wielokrotnie powtarzają się te same fragmenty kodu. Stoi to zarówno w sprzeczności z zasadą wydajnego programowania, która zakłada oddzielanie tego co wspólne, od tego co indywidualne i nie daje gwarancji oprogramowania wszystkich możliwych kombinacji przetwarzania, jakich może potrzebować w przyszłości programista. Rozwiązaniem tego problemu jest zastosowanie wzorca projektowego dekoratora (ang. decorator), który obudowuje obiekt pierwotny i dodaje do jego funkcjonalności nowe działania. Każdy z dekoratorów może dodawać określone operacje przez przyrostowe dołączanie go do innego dekoratora, co pozwala na bardzo elastyczne i selektywne podczepianie nowej funkcjonalności do wybranych instancji.

Przykładem wykorzystania dekoratorów w języku Java jest pakiet klas obsługujących sumienie wejścia i wyjścia. Dzięki takiej architekturze wszystkie działania, jakie programista chce sekwencyjnie wykonać na odczytywanych zbiorach danych, można zaimplementować w klasach dekoratorów, filtrujących odbierane lub wysyłane bajty oraz znaki. Po opakowaniu pierwotnego strumienia w odpowiednie zestawy dekoratorów filtrujących, operacje które zostały w ten sposób dołączone stają się z punktu widzenia pracy ze strumieniem integralną częścią procesu odczytu lub zapisu danych.

Naturalnym rozszerzeniem takiej konwencji jest zastosowanie w opisywanym programie własnych dekoratorów wejścia i wyjścia, filtrujących strumienie pod kątem potrzeb aplikacji. Klasy implementujące sieci neuronowe i algorytmy uczenia wykorzystują w procesie konstrukcji wielowymiarowe tablice liczb, w których zawarte są wagi poszczególnych neuronów oraz obserwacje odpowiadające zmiennym wejściowym i wyjściowym. Informacje te są odczytywane z plików tekstowych o ustalonym formacie, osobnym dla struktury sieci neuronowych i zestawów obserwacji. Format wszystkich plików wykorzystywanych przez program dopuszcza ponadto występowanie jednowierszowych komentarzy rozpoczynających się znakami "//". Zadaniem klas dekoratorów filtrujących strumienie na potrzeby omawianej aplikacji jest usuwanie komentarzy z odczytywanych linii oraz zamiana tekstu na tablice liczb, przekazywane później konstruktorom klas sieci neuronowych.

Przykład 4.9. Kasa dekoratora filtrującego strumień wejściowy w programie NeuralEngine

public class NeuralEngineReader extends BufferedReader {
/*--------------------------------------------------------*/
	public NeuralEngineReader(Reader in) {
		super(in);
	}
/*--------------------------------------------------------*/
	public NeuralEngineReader(Reader in, int bufferSize) {
		super(in, bufferSize);
	}
/*--------------------------------------------------------*/
	public String readLine() throws IOException {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[][][] readNetwork() throws IOException, NumberFormatException {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[][][] readData() throws IOException, NumberFormatException {
		// detailed operations
}
}

Wykorzystanie dekoratora, zamiast implementowania potrzebnej funkcjonalności w nowej klasie pochodnej, nie ogranicza kodu jedynie do określonego typu strumienia, ale pozwala na korzystnie ze wszystkich dostępnych strumieni, a jedna i ta sama klasa może współpracować ze standardowym wejściem, plikami lub porcjami danych przesyłanymi przez sieć. Podobną strukturę posiada klasa dekoratora dla strumieni wyjściowych, w której zamieszczone wyżej funkcje odczytu zostały zastąpione funkcjami zapisującymi do strumienia znakowego zawartość odpowiednich tablic liczb. W zaprezentowanej w powyższym przykładzie klasie występuje dodatkowo funkcja nadpisująca public String readLine() z klasy BufferedReader, która pozwala odczytywać pojedyncze linie tekstu pozbawione wspomnianych wcześniej komentarzy jednowierszowych.

Wykorzystanie koncepcji mostu w interakcji obiektów neuronów i sieci neuronowej

Różnorodność sieci neuronowych, nawet ograniczonych do zbioru jednokierunkowych, wielowarstwowych sieci percepronowych, przejawia się między innymi w rodzajach typów neuronów, a w zasadzie wielości funkcji aktywacji używanej w neuronach stosowanych w konkretnych architekturach. Naturalne wydaje się więc dążenie do uniknięcia wpisywania na stałe do kodu sieci jednego typu neuronu. Rozwiązaniem najodpowiedniejszym w tej sytuacji jest zastosowanie wzorca projektowego mostu (ang. bridge), który zgodnie z tym co sugeruje nazwa, służy do łączenia ze sobą dwóch oddzielnych hierarchii klas. Wzorzec ten wymaga zdefiniowania stałego, jednolitego interfejsu, który następnie będzie zaimplementowany przez jedną ze stron mostu. Wspomniany interfejs jest następnie wykorzystywany do komunikacji, przez obiekty należące do drugiej hierarchii klas i obejmujące na zasadach kompozycji implementacje konkretnych klas z hierarchii pierwszej. Zastosowanie takiej architektury pozwala na jednostronne modyfikowanie lub rozszerzanie hierarchii klas stanowiących wybraną stronę mostu, bez potrzeby przerabiania lub nawet rekompilacji klas należących do jego przeciwnej strony. Tak długo jak obiekty komunikują się za pomocą ustalonego interfejsu i przekazują sobie poprawne dane, jest możliwe ich efektywne współdziałanie. Kod klienta jest niezależny od nowych wersji dostarczanych klas, a same klasy w jeszcze większym stopniu mogą ukryć szczegóły swojej implementacji przed klientem.

W analizowanym programie koncepcja pomostu łączy ze sobą implementacje różnych typów neuronów z obiektami należącymi do różnych klas sieci neuronowych. Neurony należące do warstw perceptronowych posiadają jednolity interfejs, którego rozszerzanie pozwala na określenie formy funkcji aktywacji i tym samym sposobu obliczania wartości wyjścia.

Przykład 4.10. Interfejs perceptronów w postaci bazowej klasy abstrakcyjnej

public abstract class Neuron implements Cloneable {
/*--------------------------------------------------------*/
	protected double[] weights;
/*--------------------------------------------------------*/
	public Neuron() {}
/*--------------------------------------------------------*/
	public Neuron(double[] weights) {
		this.setWeights(weights);
	}
/*--------------------------------------------------------*/
	public Object clone() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public void setWeights(double[] weights) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getWeights(){
		// detailed operations
	}
/*--------------------------------------------------------*/
	public abstract double getOutput(double input);
/*--------------------------------------------------------*/
	public abstract double getOutput(double[] input)
		throws InvalidNeuronInputException;
}

Podczas tworzenia obiektu sieci neuronowej do konstruktora przekazywana jest implementacja konkretnej klasy neuronu, przypisywana następnie do jednego z pól sieci. Obiekt sieci nie wie jaki typ neuronu został mu przekazany, ale dzięki znajomości jego interfejsu, do którego neuron jest wcześniej rzutowany, może się z nim komunikować bez żadnych przeszkód. Kod programu obejmuje pewną ilość typów sieci neuronowych, które najogólniej można podzielić na generujące wyłącznie wartości wyjściowe oraz posiadające metody przeznaczone do trenowania. Wymienione grupy klas korzystają z dwóch różnych interfejsów neuronów, z których pierwszy został przedstawiony w powyższym przykładzie, a drugi będący jego rozszerzeniem dołącza metody przydatne podczas trenowania. Ponadto typowy konstruktor konkretnej klasy sieci neuronowej wymaga podania trzech obiektów w postaci: neuronu warstwy wejściowej, wykonującej przetwarzanie wstępne, neuronu warstw perceptronowych oraz neuronu warstwy wyjściowej, wykonującej najczęściej przeskalowanie danych.

Przykład 4.11. Implementacja podstawowej sieci neuronowej typu MLP

public class MLPNetwork implements Network {
/*--------------------------------------------------------*/
	protected IONeuron[] inputLayer;
	protected Neuron[][] perceptrons;
	protected IONeuron[] outputLayer;
/*--------------------------------------------------------*/
	public MLPNetwork(double[][][] weights) {
		this(new Rescaling0To1IONeuron(), new LogisticNeuron(), new Rescaling0To1IONeuron(), weights);
	}
/*--------------------------------------------------------*/
	public MLPNetwork(IONeuron input, Neuron perceptron, IONeuron output, double[][][] weights) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getOutput(double[] input) throws InvalidNeuronInputException, InvalidNetworkInputException {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[][] getOutput(double[][] input) throws InvalidNeuronInputException, InvalidNetworkInputException {
		// detailed operations
	}
}

Neuron jako implementacja wzorca prototypu

Sieć neuronowa składa się najczęściej ze sporej liczby zarówno neuronów wykonujących przetwarzanie wstępnie oraz końcowe, jak i właściwych perceptronów. Z tego powodu przekazywanie po jednym obiekcie z każdego typu, wymagane w konstruktorach sieci, nie dostarcza odpowiedniej ilości elementów składowych. Aby mieć możliwość uzyskania dowolnej liczby potrzebnych neuronów, niezbędne jest zaimplementowanie ich w postaci konstrukcyjnego wzorca projektowego prototypu (ang. prototype). Wzorzec prototypu pozwala na otrzymanie nowej instancji klasy z obiektu już istniejącego. Proces ten nazywany klonowaniem, zakłada skopiowania całości obiektu źródłowego do obiektu docelowego. Klonowanie obiektu, zamiast tradycyjnego tworzenia z użyciem konstruktora, jest szczególnie przydatne gdy proces konstrukcji wymaga np. dużej ilości czasu i jest dość kłopotliwy, a skopiowanie i odpowiednie zmodyfikowanie zawartości nowego obiektu nie stanowi większego problemu. Przykładem takiej sytuacji może być pozyskiwanie i przetwarzanie odpowiedzi na zapytanie do bazy danych. Korzystanie z dziedziczenia i właściwości polimorficznych prowadzi też często do operowania lub przechowywania obiektów bez znajomości ich rzeczywistych typów, w postaci rzutowanej do klasy bazowej lub wspólnego interfejsu. Wówczas, jedyną możliwością stworzenia dodatkowych obiektów jest właśnie sklonowanie już istniejącego.

W celu zaimplementowania właściwości klonowania w języku Java należy posłużyć się funkcją protected Object clone(), należącą do klasy Object, będącej typem bazowym wszystkich klas. Ograniczenie zasięgu tej funkcji w postaci identyfikatora protected (chroniony) sprawia, że jest ona dostępna wyłącznie dla klas dziedziczących z Object i może być wywołana tylko na rzecz tej samej klasy lub własnej klasy bazowej. W celu usunięcia wspomnianego ograniczenia należy w klasie pochodnej przesłonić funkcję Object clone(), czyniąc ją jednocześnie metodą publiczną (public). W przesłoniętej metodzie możemy następnie wywołać oryginalną funkcję protected Object clone() klasy podstawowej. Efektem jej działania jest uzyskanie kopii instancji rzutowanej do podstawowego typu Object. Przeprowadzone w ten sposób kopiowanie określa się mianem płytkiego, ponieważ przepisaniu ulega jedynie sam obiekt, podczas gdy zawartość wszystkich pół, z wyjątkiem pół z typami prymitywnymi, wskazuje na te same instancje, do których odwołują się pola obiektu źródłowego. Przeprowadzenie kopiowania głębokiego, czyli przepisanie obiektu razem z całą siecią przypisanych do niego bezpośrednio lub pośrednio instancji, wymaga bardziej złożonej implementacji funkcji public Object clone(), dopasowanej ponadto indywidualnie do poszczególnych klas.

Klasa która może podlegać klonowaniu musi także implementować interfejs Cloneable, który nie pełni w tym przypadku tradycyjnej roli interfejsów dostarczających wspólne metody, ale jest swego rodzaju oznaczeniem i potwierdzeniem możliwości klonowanie wybranej klasy.

W celu dostarczenia możliwości klonowania neuronów w obiektach sieci, w klasie bazowej każdego neuronu uwzględniono wszystkie wyżej wymienione warunki. Oznacza to że klasa Neuron implementuje interfejs Cloneable, udostępnia metodę public Object clone() i w razie potrzeby wykonuje w przesłoniętej funkcji głębokie kopiowanie obiektu.

Przykład 4.12 Funkcja public Object clone() prezentowanej we wcześniejszym podrozdziale klasy Neuron

	public Object clone() {
		
		Object object = null;
		
		try {
			object = super.clone();
		} catch (CloneNotSupportedException e) {}
		
		if (this.weights == null) return object;
		else {
			((Neuron)object).setWeights(this.getWeights());
			return object;
		}
	}

Klasy bazowe konkretnych klas neuronów jako przykłady wzorca szablonu

Jednym z fundamentalnych założeń programowania zorientowanego obiektowo jest oddzielanie tego co wspólne dla określonej rodziny klas i zamykanie w jednej, abstrakcyjnej klasie bazowej. Ta powszechnie stosowana praktyka jest istotą wzorca projektowego szablonu (ang. template), stosowanego na co dzień przez programistów, często bez znajomości terminologii związanej z wzorcami projektowymi. Sama nazwa "szablon" może być dość myląca, ponieważ w języku C++ występuje mechanizm określony identycznym słowem, a implementujący nieco inną koncepcję. Wzorzec szablonu to swego rodzaju szkielet w postaci abstrakcyjnej kasy bazowej, w oparciu o który tworzone są kolejne konkretne klasy pochodne. Hermetyzuje on wspólne dla określonej hierarchii dziedziczenia operacje i uwalnia od konieczności powtarzania w wielu klasach pochodnych tych samych fragmentów kodu. Sercem kasy szablonu są więc jej metody, wśród których możemy wyróżnić następujące typy:

  1. Metody konkretne, w całości implementujące pojedyncze operacje i przeznaczone do wykorzystania w klasach pochodnych.
  2. Metody abstrakcyjne, czyli inaczej metody puste przeznaczone do oprogramowania w kolejnych klasach wywiedzionych z szablonu.
  3. Metody zawierające częściową lub szkieletową implementację potrzebnych algorytmów, których nadpisanie nie jest wymagane, ale które z definicji są przeznaczone do przesłonięcia.
  4. Metody szablonowe, będące kombinacją wywołań funkcji wymienionych powyżej. Ich zadaniem jest ogóle opisanie potrzebnego algorytmu, bez wdawania się w szczegóły implementacji, których skonkretyzowanie następuje w klasach pochodnych.

Przykłady szablonów, wykorzystanych w opisywanym systemie sieci neuronowych, były już omawiane przy okazji prezentowania innych wzorców projektowych. Należy do nich między innymi klasa CommandInterpreter, która dostarcza zestawu metod służących to przetwarzania wyrazów składających się na polecenia analizowane w konkretnych klasach interpreterów. Abstrakcyjna klasa bazowa wszystkich neuronów (Neuron) także definiuje metody wykorzystywane w wielu klasach pochodnych, a służące w tym przypadku do pobierania i ustawiania wartości wag. Jest ona jednocześnie klasą bazową dla kolejnego szablonu (OINeuron), dostarczającego interfejsu dla neuronów warstwy wejściowej oraz wyjściowej. Najbardziej jednak rozbudowanym przykładem wzorca szablonu jest klasa EBPNetwork, która implementuje wspólną funkcjonalność sieci neuronowych trenowanych za pomocą metod gradientowych i wstecznej propagacji błędu. Jej metody dostarczają prostego sposobu na wykonywanie niezbędnych podczas obliczeń neuronowych działań na wektorach, pozwalają na wyznaczanie miar błędów oraz wykonują cały szereg operacji związanych z obliczaniem poprawek dla wag wszystkich perceptronów sieci. Dzięki skupieniu wymienionych operacji w jednej klasie bazowej, implementowanie sieci trenowanych zgodnie z konkretnymi odmianami algorytmu wstecznej propagacji błędu, staje się zadaniem prostym i efektywnym.

Przykład 4.13. Implementacja szablonu sieci neuronowej uczonej za pomocą algorytmów opartych na wstecznej propagacji błędu

public abstract class EBPNetwork extends MLPNetwork implements IterativeTrainable, Superviseable {
/*--------------------------------------------------------*/
	protected double[][] input;
	protected double[][] output;
	protected double[][] pattern;
/*--------------------------------------------------------*/
	protected static double[][][] scalarProduct(double scalar, double[][][] vector) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	protected static double[][][] sumOfWeights(double[][][] vector1, double[][][] vector2) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public EBPNetwork(double[][][] weights, double[][][] data) throws InvalidNeuronInputException, InvalidNetworkInputException, InvalidNetworkPatternException {
		this(new Rescaling0To1IONeuron(), new LogisticEBPNeuron(), new Rescaling0To1IONeuron(), weights, data);
	}
/*--------------------------------------------------------*/
	public EBPNetwork(IONeuron input, Neuron perceptron, IONeuron output, double[][][] weights, double[][][] data) throws InvalidNeuronInputException, InvalidNetworkInputException, InvalidNetworkPatternException {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getME() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getMPE() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getRMSE() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getMPE() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getMAPE() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getRMSPE() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getMPE2() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getMAPE2() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[] getRMSPE2() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double getError() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	public double[][][] getWeights() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	protected void setPerceptronsWeights(double[][][] weights) {
		// detailed operations
	}
/*--------------------------------------------------------*/
	protected double[][][] getPerceptronsWeights() {
		// detailed operations
	}
/*--------------------------------------------------------*/
	protected double[][][] getCorrection(double[] input, double[] pattern) throws InvalidNeuronInputException, InvalidNetworkInputException, InvalidNetworkPatternException {
		// detailed operations
	}
/*--------------------------------------------------------*/
	private double[][] getLayersOutputs(double[] input) throws InvalidNeuronInputException, InvalidNetworkInputException {
		// detailed operations
	}
/*--------------------------------------------------------*/
	private double[] getTransformedPattern(double[] pattern) throws InvalidNeuronInputException, InvalidNetworkPatternException {
		// detailed operations

	}
}

Podsumowanie

Niezbędnym elementem praktycznie każdej sieci neuronowej jest komputer oraz odpowiednie oprogramowanie. W przypadku gdy sieć neuronowa stanowi jedynie narzędzie, użytkownik nie musi martwić się o szczegóły algorytmów lub implementacji i w wygodny sposób może korzystać z dostępnych, popularnych pakietów aplikacji neuronowych. Zajmując się jednak istotą obliczeń neuronowych pojawia się konieczność samodzielnej budowy odpowiedniego systemu komputerowego, realizującego dowolne koncepcje i pozwalającego na pełne kontrolowanie szczegółów obliczeń.

Głównym celem podczas implementowania systemu sieci neuronowych i algorytmów ich uczenia było dążenie do osiągnięcie jak największej uniwersalności, zarówno pod względem rozbudowy oraz możliwości wielokrotnego wykorzystania kodu, jak i sposobów używania gotowej aplikacji. Względy wynikające z efektywności kodowania skłoniły do sięgnięcia po język programowania o charakterze obiektowym oraz koncepcję wzorców projektowych, będącą bardziej skonkretyzowanym rozwinięciem idei obiektowości. Użycie Javy otworzyło ponadto interesujące możliwości rozbudowy aplikacji w kierunku systemów sieciowych oraz rozproszonych, pozwalając jednocześnie na wygodne, wysokopoziomowe oprogramowanie istoty obliczeń neuronowych.

Omawiana aplikacja o roboczej nazwie NeuralEngine została pomyślana jako swego rodzaju silnik neuronowy, możliwy do wykorzystania samodzielnego, w postaci systemu z interfejsem konsolowym, lub jako program współpracujący z innymi modułami. Jej zaletą jest możliwość odczytywania i wykonywania całej sekwencji komend zapisanych w pliku tekstowym, możliwość przetwarzania poleceń dołączonych do jej wywołania w postaci parametrów oraz całkowite sparametryzowanie zaimplementowanych operacji. Sposób obsługi aplikacji można we względnie prosty sposób poszerzyć o interfejs graficzny, który może być formą nakładki na już istniejący program lub wersją rozbudową, ingerującą w jego kod. Ingerencja w algorytmy systemu oznacza skorzystanie z implementacji standardowych wzorców projektowych, a konserwacja i rozwijanie programu zbudowanego z takich uniwersalnych i łatwo rozpoznawalnych elementów składowych również nie jest zadaniem skomplikowanym.

Kod programu jest podzielony na trzy logiczne części. Pierwsza z nich, zawarta w pakiecie rosczak.neuralengine10 obejmuje implementację wzorca projektowego interpretera, służącego do analizy komend wprowadzanych przez użytkownika. Jego struktura jest najbardziej rozbudowana i korzysta w realizowanej strategii z wzorców trzech rodzajów fabryk oraz drzewiastej architektury łańcucha odpowiedzialności. Po zidentyfikowaniu polecenia i jego parametrów, sterowanie przekazywane jest do drugiej części programu, zajmującej się jego wykonaniem oraz interakcją z użytkownikiem i systemem plików. Cały proces odbywa się w obiektach implementujących wzorzec projektowy polecenia i korzystających ze strumieni wejściowych oraz wyjściowych, których operacje zostały rozszerzone w oparciu o specjalne klasy dekoratorów. Wykonywanie poleceń wiąże się jednak przede wszystkim z tworzeniem i operowaniem klasami sieci neuronowych, zamkniętymi w oddzielnym pakiecie o nazwie rosczak.neural10. Wspomniany pakiet stanowi trzecią logiczną cześć programu zajmującą się wyłączne szczegółami obliczeń neuronowych. W celu oddzielenia klas sieci od różnych typów neuronów ich kompozycja odwołuje się do koncepcji wzorca strukturalnego mostu, a konkretne klasy obydwu hierarchii zbudowane są na szkieletach implementujących najczęściej stosowany wzorzec szablonu.

Mimo szeregu wygodnych mechanizmów umożliwiających jednostronne modyfikowanie kodu bibliotek lub pakietów bez potrzeby ingerowania w kod z nich korzystający, muszą one podlegać konserwacji oraz rozwijaniu na takiej samej zasadzie, jak wszystkie inne części oprogramowania. Tworzenie nowych wersji pakietów w języku Java i nacisk na unikalność ich nazw, wymusza w przypadku zmiany interfejsów publicznych nadawanie im nowych oznaczeń. W omawianym programie, jako rozwiązanie tego problemu zaproponowano uczynienie z numeru wersji integralnej części składowej unikalnej nazwy pakietu.

Zastosowane w programie koncepcje oraz mechanizmy mogą oznaczać podniesienie ogólnego poziomu komplikacji i początkowego nakładu pracy, w miarę jednak postępów programowanie zaczyna przypominać składanie całości programu z uniwersalnych elementów, przejrzyście oddających istotę algorytmu i zostawiających cały wachlarz możliwości dalszego rozwoju.

Składnia poleceń programu NeuralEngine v1.0

W nawiasach kwadratowych "[]" znajdują się parametry opcjonalne oraz opis rodzaju argumentów. Objaśnienia poleceń i parametrów zostały ujęte w postaci komentarzy jednowierszowych rozpoczynających się od "//".

error // oblicz miary błędów
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (zestawienia błędów)

exit // zakończ program

generate [observations] [using] interpolation // generuj nowe obserwacje przy użyciu interpolacji
    [for]
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (zbioru obserwacji)

output // oblicz wyjścia sieci dla zadanych danych wejściowych
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (zbioru wartości wejść i wyjść)

randomize [weights] [using] uniform [distribution] // generuj losowo (rozkład równomierny) wartości dla wag sieci neuronowej
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    [with]
    min [float value, default value -0.5] // wartość minimalna
    max [float value, default value 0.5] // wartość maksymalna
    ]

execute [script] [String fileName] // wykonaj skrypt o podanej nazwie

split [randomly] // podziel losowo zbiór obserwacji na dwa odrębne zbiory
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    first [set] [into] String fileName] // nazwa pierwszego plik wynikowy (zbioru obserwacji)
    second [set] [into] [String fileName] // nazwa drugiego pliku wynikowego (zbioru obserwacji)
    [
    [with]
    proportion [float value, default value 0.5] // wielkość pierwszego zbioru
    to [float value, default value 0.5] // wielkość drugiego zbioru
    ]

train [using] aebp [batch] // trenuj sieć neuronową wykorzystując kątową metodę wstecznej propagacji błędu i wsadowy sposób uczenia
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    report [into] [String fileName, default value report.txt] // nazwa pliku wynikowego (raportu z zapisem iteracji)
    [with]
    step [float value, default value 1.0] // współczynnik uczenia (krok)
    [learning rate] modifier [float value, default value 0.0001] // modyfikator współczynnika uczenia (kroku)
    momentum [float value, default value 1.0] // współczynnik momentu
    [max] error [float value, default value 0.01] // maksymalny błąd
    [max] iterations [integer value, default value 100000] // maksymalna liczba iteracji
    [min] progress [float value, default value 1e-100] // minimalny spadek błędu w iteracji
    [stop] delay [integer value, default value 10000] // liczba dodatkowych iteracji
    ]

train [using] esaebp [batch] // trenuj sieć neuronową wykorzystując algorytm wczesnego stopu, kątową metodę wstecznej propagacji błędu i wsadowy sposób uczenia
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    construction [set] [String fileName] // nazwa pliku źródłowego (zbioru konstrukcyjnego)
    test [set] [String fileName] // nazwa pliku źródłowego (zbioru testowego)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    report [into] [String fileName, default value report.txt] // nazwa pliku wynikowego (raportu z zapisem iteracji)
    [with]
    step [float value, default value 0.7] // współczynnik uczenia (krok)
    [learning rate] modifier [float value, default value 0.0001] // modyfikator współczynnika uczenia (kroku)
    momentum [float value, default value 0.9] // współczynnik momentu
    [max] iterations [integer value, default value 100000] // maksymalna liczba iteracji
    [stop] delay [integer value, default value 1000] // liczba dodatkowych iteracji
    ]

train [using] esmebp batch // trenuj sieć neuronową wykorzystując algorytm wczesnego stopu, momentową metodę wstecznej propagacji błędu i wsadowy sposób uczenia
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    construction [set] [String fileName] // nazwa pliku źródłowego (zbioru konstrukcyjnego)
    test [set] [String fileName] // nazwa pliku źródłowego(zbioru testowego)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    report [into] [String fileName, default value report.txt] // nazwa pliku wynikowego (raportu z zapisem iteracji)
    [with]
    step [float value, default value 0.7] // współczynnik uczenia (krok)
    momentum [float value, default value 0.9] // współczynnik momentu
    [max] iterations [integer value, default value 100000] // maksymalna liczba iteracji
    [stop] delay [integer value, default value 1000] // liczba dodatkowych iteracji
    ]

train [using] esmebp incremental // trenuj sieć neuronową wykorzystując algorytm wczesnego stopu, momentową metodę wstecznej propagacji błędu i przyrostowy sposób uczenia
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    construction [set] [String fileName] // nazwa pliku źródłowego (zbioru konstrukcyjnego)
    test [set] [String fileName] // nazwa pliku źródłowego (zbioru testowego)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    report [into] [String fileName, default value report.txt] // nazwa pliku wynikowego (raportu z zapisem iteracji)
    [with]
    step [float value, default value 0.7] // współczynnik uczenia (krok)
    momentum [float value, default value 0.9] // współczynnik momentu
    [max] iterations [integer value, default value 100000] // maksymalna liczba iteracji
    [stop] delay [integer value, default value 1000] // liczba dodatkowych iteracji
    ]

train [using] mebp batch // trenuj sieć neuronową wykorzystując momentową metodę wstecznej propagacji błędu i wsadowy sposób uczenia
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    report [into] [String fileName, default value report.txt] // nazwa pliku wynikowego (raportu z zapisem iteracji)
    [with]
    step [float value, default value 0.7] // współczynnik uczenia (krok)
    momentum [float value, default value 0.9] // współczynnik momentu
    [max] error [float value, default value 0.01] // maksymalny błąd
    [max] iterations [integer value, default value 100000] // maksymalna liczba iteracji
    [min] progress [float value, default value 1e-100] // minimalny spadek błędu w iteracji
    [stop] delay [integer value, default value 10000] // liczba dodatkowych iteracji
    ]

train [using] mebp incremental // trenuj sieć neuronową wykorzystując momentową metodę wstecznej propagacji błędu i przyrostowy sposób uczenia
    [for]
    network [String fileName] // nazwa pliku źródłowego (struktury sieci)
    data [String fileName] // nazwa pliku źródłowego (zbioru obserwacji)
    [send]
    result [into] [String fileName] // nazwa pliku wynikowego (struktury sieci)
    [
    report [into] [String fileName, default value report.txt] // nazwa pliku wynikowego (raportu z zapisem iteracji)
    [with]
    step [float value, default value 0.7] // współczynnik uczenia (krok)
    momentum [float value, default value 0.9] // współczynnik momentu
    [max] error [float value, default value 0.01] // maksymalny błąd
    [max] iterations [integer value, default value 100000] // maksymalna liczba iteracji
    [min] progress [float value, default value 1e-100] // minimalny spadek błędu w iteracji
    [stop] delay [integer value, default value 10000] // liczba dodatkowych iteracji
    ]

Struktura plików wykorzystywanych przez program NeuralEngine v1.0

Przedstawiony poniżej format pliku z obserwacjami używany jest podczas wszystkich operacji związanych z wczytywaniem i zapisem danych składających się na wektor wejść i wyjść sieci neuronowej. W przypadku niektórych poleceń, jak np. obliczania wartości zmiennych wyjściowych, pliki źródłowe oraz wynikowe zawierają informacje nadmiarowe, jak np. zbiór wartości wyjść z pliku źródłowego, który jest zbędny w tej operacji. Aplikacja, mimo że ignoruje takie dane i nie ma znaczenia jakie liczby zostaną wpisane we wspomnianych pozycjach, wymaga pliku zgodnego z przedstawionym schematem.

[n - liczba obserwacji]

[pusty wiersz]

[i - liczba wejść]

[j - liczba wyjść]

[pusty wiersz]

[1 wartość wejścia nr 1][spacja] ... [spacja][n-ta wartość wejścia nr 1]

...

[1 wartość wejścia nr i][spacja] ... [spacja][n-ta wartość wejścia nr i]

[pusty wiersz]

[1 wartość wyjścia nr 1][spacja] ... [spacja][n-ta wartość wyjścia nr 1]

...

[1 wartość wyjścia nr j][spacja] ... [spacja][n-ta wartość wyjścia nr j]

Przykład:

4 // liczba obserwacji

3 // liczba wejść
1 // liczba wyjść

0.026 0.041 0.068 0.070 // dane wejścia nr 1
0.385 0.068 0.140 0.279 // dane wejścia nr 2
0.143 0.131 0.103 0.149 // dane wejścia nr 3

0.215 0.182 0.255 0.220 // dane wyjścia nr 1

Schemat pliku ze strukturą sieci neuronowej i wartościami wag poszczególnych neuronów:

[i - liczba warstw sieci neuronowej]

[pusty wiersz]

[j - liczba neuronów w warstwie nr 1]

...

[k - liczba neuronów w warstwie nr i]

[pusty wiersz]

[n - liczba wejść (wag) neuronów warstwy nr 1]

[1 waga 1 neuronu warstwy nr 1][spacja] ... [spacja][ n-ta waga 1 neuronu warstwy nr 1]

...

[1 waga j-tego neuronu warstwy nr 1][spacja] ... [spacja][ n-ta waga j-tego neuronu warstwy nr 1]

[pusty wiersz]

...

[pusty wiersz]

[m - liczba neuronów w warstwie nr i]

[1 waga 1 neuronu warstwy nr i][spacja] ... [spacja][ m-ta waga 1 neuronu warstwy nr i]

...

[1 waga k-tego neuronu warstwy nr i][spacja] ... [spacja][ m-ta waga k-tego neuronu warstwy nr 1]

Przykład:

3 // liczba warstw

3 // liczba neuronów w warstwie nr 1
2 // liczba neuronów w warstwie nr 2
1 // liczba neuronów w warstwie nr 3

2 // liczba wejść (wag) neuronów warstwy nr 1
0.0 0.5 // wagi neuronu nr 1
0.0 0.3 // wagi neuronu nr 2
0.0 0.6 // wagi neuronu nr 3

3 // liczba wejść (wag) neuronów warstwy nr 2
0.244109 0.009272 0.126451 // wagi neuronu nr 1
0.225867 0.067135 0.256491 // wagi neuronu nr 2

2 // liczba wejść (wag) neuronów warstwy nr 3
0.0 0.5 // wagi neuronu nr

Strona domowa
Spis treści
Dalej

Copyright © by Paweł Rośczak, 2003