Bibioteka Graph Data Science w bazie Neo4j - algorytmy Node Similarity & Louvain

Rekomendacje bazujące na poprzednich zakupach klientów

Algorytm podobieństwa węzłów porównuje zbiór węzłów na podstawie węzłów, z którymi są one połączone. Dwa węzły są uważane za podobne, jeśli mają wielu takich samych sąsiadów. Funkcja podobieństwa węzłów oblicza podobieństwa parami na podstawie metryki Jaccarda, znanej również jako wynik podobieństwa Jaccarda, lub współczynnika nakładania się, znanego również jako współczynnik Szymkiewicza-Simpsona.

Metoda Louvaina to algorytm służący do wykrywania społeczności w dużych sieciach. Maksymalizuje wynik modułowości dla każdej społeczności, gdzie modułowość określa ilościowo jakość przypisania węzłów do społeczności. Oznacza to ocenę, o ile gęściej połączone są węzły w obrębie społeczności, w porównaniu z tym, jak połączone byłyby w losowej sieci.

Algorytm Louvaina jest algorytmem hierarchicznego grupowania, który rekurencyjnie łączy społeczności w jeden węzeł i wykonuje grupowanie modułowości na skondensowanych grafach. Algorytm Louvaina jest niedeterministyczny i dlatego niektóre węzły czasami są grupowane w społeczności, a czasami nie. To oznacza że rezultaty mogą być różne w różnych uruchomieniach programu.

Algorytm został opracowany na beligijskim Universytecie Louvain niedaleko Brukseli.

Uruchomienie biblioteki GDS

Bibliotekę dla Neo4j Server można pobrać ze strony https://neo4j.com/deployment-center/#gds-tab

Po rozpakowaniu pliku *.zip kopiujemy JAR do katalogu 'plugins' i modyfikujemy plik konfiguracyjny Neo4j ($NEO4J_HOME/conf/neo4j.conf) wstawiając w nim linie:

dbms.security.procedures.unrestricted=gds.*

Następnie pozwalamy na wykonywanie procedur z użyciem tej biblioteki wstawiająć kolejną linie w tym samym pliku konfiguracyjnym:

dbms.security.procedures.allowlist=gds.*

Po wgraniu pliku uruchom serwer ponownie by załadowac bliblioteką.

Sprawdzanie wersji oraz listy dostępnych algorytmów

Jeśli chcesz sprawdzić wersję biblioteki GDS uruchom polecenie:

RETURN gds.version();

Listę dostępnych procedur i algorytmów sprawdzisz uruchamiając polecenie:

CALL gds.list();

Budowa procesu rekomendacji produktów

Naszym celem jest zbudowanie procesu/silnika rekomendacji produktów bazującego na poprzednich zakupach klientów. Docelowo chcemy wykryć klastry klientów [przeczytaj niżej czym różni się klastrowanie od segmentacji] których wspólną  cechą są podobne zakupy; zakupili pewną ilość takich samych produktów (choć także kupili i inne artykuły). Mając klastry klientów, możemy sporządzić listę najczęściej artykułów przez tych klientów. A skoro wiemy co inni podobni klienci kupują i czego nie kupił nasz klient, możemy mu taki produkt/produkty zaproponować.

Krokiem pośrednim będzie znalezienie podobieństw między klientami; określenie który klient jest podobny do którego pod kątem dokonanych zakupów. Sama informacja o podobieństwie może być także użyta w procesie rekomendacji; wiemy co kupili podobni klienci ale także możemy poznać które produkty są w ich zakupach wykazują takie podobieństwo. Klient który kupił produkt X może być także zainteresowany produktem Y ponieważ były one kupowane często wspólnie. Co więcej, znając zakupiony artykuł, możemy dowiedzieć się co jeszcze najczęściej kupowane jest przy zakupie tego właśnie produktu.

Przeczytaj też artykuł "Jak zbudować wydajny system rekomendacji produktów" na naszym blogu w którym objaśniamy jak z pomocą Neo4j stworzymć system rekomendacji produktów bazujący na zakupach osób które dokonały podobnych wyborów jak klient któremu będziemy rekomendować produkty (collaborative filtering). System z dużą skutecznością podpowie produkty, którymi naprawdę może być zainteresowany nasz klient.

Klastrowanie vs segmentacja

Klastrowanie wykorzystuje algorytmy do identyfikowania podobieństw w danych klientów. Mówiąc najprościej, algorytmy przeglądają dane klientów, wychwytują podobieństwa, które ludzie mogli przeoczyć, i grupują klientów w oparciu o wzorce ich zachowań.

Kiedy marketer wybiera kryteria, aby wyciągnąć określone grupy z dużego zbioru danych, jest to segmentacja. Innymi słowy, ma to miejsce wtedy, gdy patrzysz na dane swoich klientów i wybierasz konkretne kryteria, do których chcesz dotrzeć do danej grupy.
To tutaj ludzie używają głowy i, co dziwne, serca opartego na danych, aby dotrzeć do określonych grup i drążyć temat tak szczegółowo, jak chcą. Tam, gdzie klastry dostarczają informacji, segmentacja pozwala "marketerowi" wybierać i celowo kierować określone grupy w celu spersonalizowanego przekazu.

Przygotowanie danych - Grafy

Nie możesz uruchamiać algorytmów biblioteki GDS bezpośrednio na danych bazy Neo4j. Musisz użyć struktury pośredniej - grafu. Graf w bibliotece GDS to struktura znajdująca się w pamięci zawierająca węzły połączone relacjami. Zarówno węzły, jak i relacje mogą przechowywać atrybuty numeryczne (właściwości). Wykresy są przechowywane przy użyciu skompresowanych struktur danych zoptymalizowanych pod kątem operacji wyszukiwania topologii i właściwości.

Każdy graf ma nazwę, która może służyć jako odniesienie w operacjach zarządzania lub w przepływach pracy analitycznych, które wymagają kilkukrotnego przetworzenia tego samego wykresu. Odniesienia te są przechowywane w katalogu grafów.

Utworzone grafy możesz odczytywać, modyfikować, zapisywać właściwości do istniejących danych, eksportować [do NOWYCH baz danych - miej w pamięci ograniczenia darmowej edycji Neo4j] lub plików CSV. Grafy ulegają skasowaniu w momencie zatrzymania/restartu bazy Neo4j [chyba że zrobisz ich backup].

Pamiętaj, że biblioteka Neo4j Graph Data Science nie obsługuje wszystkich typów właściwości obsługiwanych przez bazę danych Neo4j. Każdy obsługiwany typ definiuje również wartość rezerwową, która służy do wskazania, że wartość tej właściwości nie jest ustawiona.

W poniższej tabeli wymieniono obsługiwane typy właściwości, a także odpowiadające im wartości rezerwowe.

Więcej informacji na temat tworzenia grafów znajdziesz na stronie Neo4j.

Przygotowanie testowych danych

Nasze testowe dane to zakupy pięciu produktów przez czterech klientów. Niektórzy klienci kupili wielokrotnie te same produkty w różnym okresie czasu. Wizualizacja tych węzłów i relacji daje nam obraz:

Oto gotowe dane które możesz użyć do testu (wgraj do czystej bazy):

CREATE (nJohn:Client {clientid: 10, name:'John'}),
 (nAlice:Client {clientid: 20, name:'Alice'}),
 (nMark:Client {clientid: 30, name:'Mark'}),
 (nNoah:Client {clientid: 40, name:'Noah'}),
 (nOlivier:Client {clientid: 50, name:'Olivier'}),

 (nEggs:Product {name:'Eggs', productid: 310}),
 (nBread:Product {name:'Bread', productid: 320}),
 (nBeer:Product {name:'Beer', productid: 330}),
 (nSugar:Product {name:'Sugar', productid: 340}),
 (nCookies:Product {name:'Cookies', productid: 350}),

 (nJohn)-[:BOUGHT {quantity: 2, purchased: '2023-01-01' }]->(nEggs),
 (nJohn)-[:BOUGHT {quantity: 3, purchased: '2023-01-03' }]->(nEggs),
 (nJohn)-[:BOUGHT {quantity: 3, purchased: '2023-01-02' }]->(nBread),
 (nJohn)-[:BOUGHT {quantity: 5, purchased: '2023-01-01' }]->(nBeer),
 (nJohn)-[:BOUGHT {quantity: 4, purchased: '2023-01-02' }]->(nSugar),

 (nAlice)-[:BOUGHT {quantity: 3, purchased: '2023-01-06' }]->(nEggs),
 (nAlice)-[:BOUGHT {quantity: 1, purchased: '2023-01-07' }]->(nEggs),
 (nAlice)-[:BOUGHT {quantity: 1, purchased: '2023-01-09' }]->(nEggs),
 (nAlice)-[:BOUGHT {quantity: 8, purchased: '2023-01-04' }]->(nBread),

 (nMark)-[:BOUGHT {quantity: 3, purchased: '2023-01-01' }]->(nBread),
 (nMark)-[:BOUGHT {quantity: 2, purchased: '2023-01-02' }]->(nBeer),

 (nNoah)-[:BOUGHT {quantity: 6, purchased: '2023-01-02' }]->(nEggs),
 (nNoah)-[:BOUGHT {quantity: 3, purchased: '2023-01-09' }]->(nCookies),

(nOlivier)-[:BOUGHT {quantity: 1, purchased: '2023-01-11' }]->(nCookies);

Tworzenie grafu w pamięci

Wiemy, że nie możemy uruchamiać algorytmów biblioteki GDS bezpośrednio na danych bazy Neo4j. Musimy użyć struktury pośredniej - grafu. Do naszego grafu załadujemy także właściwości 'clientid' (Client) i 'productid' (Product) - posłużą nam do tego by pokazać jak można odczytać graf. Tworzymy graf:

CALL gds.graph.project(
  'purchases',
  {
    Client:{properties:'clientid'}, 
    Product:{properties:'productid'}
  },
  {BOUGHT: {}}
)

Nazwa tego grafu to 'purchases'. Pierwszy zestaw zmiennych to typy węzłów; Client i Product. Ostatnia linijka to typ relacji. Na potrzeby innych analiz moglibyśmy także określić właściwości relacji, właściwości węzłów oraz kierunek relacji. Oto przykłady jak użyć takich opcji:

//Przykład 1:
CALL gds.graph.project(
  'purchases',
  ['Client', 'Product'],
  ['BOUGHT'],
  {nodeProperties: 'productid'}
)

//Przykład 2:
CALL gds.graph.project(
  'purchases',
  {
    Client:{}, 
    Product:{properties:'productid'}
  },
  {BOUGHT: {orientation: 'UNDIRECTED'}}

)

//Przykład 3:
CALL gds.graph.project(
  'purchases',
  ['Client', 'Product'],
['BOUGHT']
)

Wykonanie algorytmów

Algorytm Node Similarity

Wykonajmy teraz algorytm Node Similarity ktory obliczy podobieństwo klientów. Parametr 'similarityCutoff' to dolna granica obecności wyniku podobieństwa w wyniku. Wartości tego paramteru muszą należeć do zakresu od 0 do 1. Chcemy pominąć w wynikach wszystkich klientów których dopasowanie wynosi poniżej 0.3

CALL gds.nodeSimilarity.stream('purchases', {similarityCutoff: 0.3})
YIELD node1, node2, similarity
RETURN 
gds.util.asNode(node1).name AS Client1,
gds.util.asNode(node2).name AS Client2,
similarity
ORDER BY similarity DESCENDING, Client1, Client2
Na ekranie powinniśmy zobaczyć podobny wynik jak niżej.
 
 
Świetnie. Algorytm najwyraźniej działa - wyniki wyglądają na prawdziwe. Algorytm Node Similarity uwzględnia właściwości powiązanych węzłów i jest odporny na zaburzenia powtórzonymi relacjami do tych samych węzłów.
Następnym krokiem jest zapisanie tych rezultatów w postaci nowych relacji które nazwiemy 'SIMILAR' a właściwość która będzie przechowywać rezultat 'similarity' nazwana będzie w tych relacjach 'score'.
Funkcja 'mutate' biblioteki GDS służy do aktualizacji grafów. Wykonany zatem raz jeszcze ten sam algorytm tym razem zapisując rezultaty do grafu:
CALL gds.nodeSimilarity.mutate(
 'purchases', {mutateRelationshipType:'SIMILAR', mutateProperty:'score',topK:10, similarityCutoff:0.30}
)

Algorytm Louvain

Dążymy to tego by zbudować klastry naszych klientów. Nie mogliśmy wykonać algorytmu Louvain bezpośrednio na grafie z relacjami 'BOUGHT' gdyż ten algorytm nie uwzględnia do jakiego produktu jest to relacja (nie bierze pod uwagę właściwości dołączonych węzłów) i niestety ilość relacji do tego samego produktu będzie mieć także wpływ na wynik. W skrócie: algorytm Louvain opiera się głównie na ilości relacji pomiędzy węzłami w celu wykrywania społeczności w grafie, nie biorąc bezpośrednio pod uwagę rodzaju węzłów.
Gdybyśmy teraz wyświetlili nasz zmodyfikowany graf funkcją 'mutate' powyżej, zobaczylibyśmy jego następującą postać (eksport grafu możliwy jest tylko w bazie wersji Enterprise):
 
 
Jak widzisz, węzły klientów połączone są teraz relacjami 'SIMILAR' z właściwościami 'score' w których zapisane jest podobieństwo jednego węzła 'Client' to drugiego. 
Możesz także przypisać te relacje do danych w bazie używając funkcji 'write' algorytmu nodeSimilarity:
 
CALL gds.nodeSimilarity.write('purchases', {
    writeRelationshipType: 'SIMILAR',
    writeProperty: 'score'
})
YIELD nodesCompared, relationshipsWritten
Wykonajmy zatem algorytm Louvain na zmodyfikowanym grafie - interesują nas wezły 'Client' z odchodzącymi relacjami 'SIMILAR' z uwzględnieniem wag relacji zapisanych we właściwościach 'score' tych relacji:
CALL gds.louvain.stream('purchases',{nodeLabels: ['Client'], relationshipTypes:['SIMILAR'], relationshipWeightProperty:'score'})
YIELD nodeId, communityId
RETURN communityId, count(nodeId) as size 
ORDER by size DESC
Na ekranie mamy rezultat; comunityId to numer klastra a size określa ile węzłów [klientów] w nim się znajduje. Klaster nr 4 zawiera klientów Noah i Olivier a klaster nr 2 to klienci John, Alice i Mark. Numery klastrów są losowe.
 
 
Możemy teraz przypisać numer klastra do wezła 'Client'. Mając taką informację przy każdym kliencie możemy dalej analizować nasze dane.
CALL gds.louvain.mutate('purchases', { mutateProperty: 'communityId' })
YIELD communityCount, modularity, modularities

Możemy też przypisać numer klastra do danych w samej bazie danych [funkcja 'write' algorytmu Louvain]:

CALL gds.louvain.write('purchases', { writeProperty: 'community' })
YIELD communityCount, modularity, modularities

Odczyt grafu

Poniżej znajdziesz dodatkowe informacje jak odczytywać informacje z grafu który znajduje się w pamięci.

Odczyt wartości właściwości w węzłach

Aby sprawdzić wartości właściwości w węzłach grafów GDS, można zastosować procedurę gds.graph.nodeProperties.stream. Jest to przydatne, jeśli uruchomiliśmy wiele algorytmów w trybie mutacji i chcemy odzyskać część lub wszystkie wyniki.

CALL gds.graph.nodeProperty.stream('purchases', 'clientid', ['*'])
YIELD nodeId, propertyValue
RETURN gds.util.asNode(nodeId).name AS name, propertyValue AS clientid
ORDER BY clientid DESC

Odczyt relacji

Aby sprawdzić tylko topologię relacji, można zastosować procedurę gds.graph.relationships.stream. Aby sprawdzić przechowywane wartości właściwości relacji, można zastosować procedurę relationsProperties.stream. Jest to przydatne, jeśli uruchomiliśmy wiele algorytmów w trybie mutacji i chcemy odzyskać część lub wszystkie wyniki.

CALL gds.graph.relationships.stream(
  'purchases'                  
)
YIELD
  sourceNodeId, targetNodeId, relationshipType
RETURN
  gds.util.asNode(sourceNodeId).name as source, gds.util.asNode(targetNodeId).name as target, relationshipType
ORDER BY source ASC, target ASC

Kasowanie grafu

Graf ulega samoistnemu skasowaniu w momencie restartu bazy. By skasować go wcześniej [np. potrzeba utworzenia nowego] wykonaj następujące polecenie:

CALL gds.graph.drop('purchases')