Natürliches Sprachverstehen mit Neo4j

10. April 2017
neo4j Logo
Haben Sie sich schon mal gefragt, wie zum Beispiel Amazon’s Alexa, Ihre Sprache verstehen kann und somit verschiedene Aktionen ausführt? Oder wie ein intelligenter Chat-Bot auf gestellte Fragen automatisch antworten kann?
 
Die drei magischen Buchstaben heißen N L P. NLP ist eine Abkürzung für Natural Language Processing und bedeutet so viel wie: „Verarbeitung von natürlicher Sprache“. Cloud Natural Language API von Google oder LUIS (Language Unterstanding Intelligent Service) von Microsoft können sogenannte Intents (engl.: Absicht) erkennen und Entitys wie Personen und Orte extrahieren.
 
Beispiel: Wie ist das Wetter in München?
 
Erkennung: 
Ort: München 
Intent: Zeige das Wetter an
 
Klingt erstmal wie Hexenwerk mit ein wenig Vodoozauber und einer Brise Feenstaub. Doch die „Magie“ dahinter ist fast immer ein selbstlernender Algorithmus. Die Intelligenz steckt hier in einem Graph. Ach ja Graph und Neo4j...da war doch was!?
 
Im Folgenden möchte ich zeigen, wie solche Intents mit Neo4j erkannt werden und einen kleinen Einblick geben, wie Entitäten extrahiert werden können. Die Grundlage und Idee kam mir beim Lesen eines Blogposts von Michael Hunger. Hier werden häufig vorkommende Phrasen in einem Text identifiziert. 
 
Stellen Sie sich nun folgendes Szenario vor:
Sie leben in einem vernetzten Haushalt und über Sprachbefehle sollen Aktionen wie: „Licht im Flur an“, „bestelle Pizza“, oder „rufe einen Techniker an“ ausgelöst werden. Die Umwandlung von Sprache zu Text, kann zunächst mit einer beliebigen Speech-To-Text API realisiert werden. Der umgewandelte Text soll dann mit Neo4j aufbereitet werden, so dass zum Beispiel erkannte Intents weitergegeben werden können, um das Licht anzuschalten. Folgende Grafik soll dies nochmal verdeutlichen.
 
 
Wie nun die natürliche Sprache mit Neo4j ausgewertet werden kann, werde ich im Folgenden zeigen. 
 

Grundgedanke

Der Grundgedanke ist, dass die Sätze in sinnvolle Teile zerlegt werden und Überflüssiges entfernt wird. Diese Teile werden als Knoten im Graph gespeichert. Die Information von nachfolgenden Teilen eines Satzes werden als Kante gespeichert. 
 
Mit der Abfragesprache Cypher, lassen sich sehr einfach zwei Wörter miteinander verbinden.
MERGE (w1:Word {name:"das"})
MERGE (w2:Word {name:"Licht"})
MERGE (w1)-[r:NEXT]->(w2)

Mit den zwei Sätzen „Schalte das Licht im Flur an“ und „Schalte das Licht in der Küche an“ entsteht zum Beispiel der folgende Graph:
 
 

Step 1: Auswahl des Tokenizers

Im ersten Schritt sollte man einen geeigneten Tokenizer auswählen. Für unseren Fall nehmen wir einen sehr einfachen Tokenizer, der die Sätze in Wörter aufteilt. Das Trennzeichen soll hier das Gap sein. Gleichzeitig sollen alle Wörter im Graph klein geschrieben werden.
WITH "Schalte das Licht im Flur an " as sentence
WITH split(tolower(sentence)," ") as words 

Step 2: Eliminierung von überflüssigen Informationen

Als Nächstes sollte man überflüssige Informationen eliminieren, die keinen Mehrwert bieten. Das hat den Vorteil, dass später wichtige Ressourcen gespart werden können. 
 
Eliminierung von Satzzeichen 
WITH reduce(oldSentence=sentence, rmvPunc in [",",";",":",".","!","?"] | replace(oldSentence,rmvPunc,"")) as sentence
Eliminierung von Stoppwörter
WITH [w in words WHERE NOT w IN ["der","die","das","den","dem"]] as words
In diesem Beispiel werden nur Artikel gefiltert. Es gibt jedoch noch sehr viel mehr Stoppwörter in einer Sprache, die eliminiert werden sollten. Eine mögliche Liste von Stoppwörtern der deutschen Sprache gibt es z.B. unter http://www.ranks.nl/stopwords/german.

Step 3: Auswahl des Lernalgorithmus

Das Ziel ist es den Graphen mit verschiedenen Sätzen zu füttern, so dass man ihm wie einem „kleinen Kind“ beibringt, welche Sätze zu welchen Intent gehören. Gibt es zusammenhängende Wörter, die sehr häufig vorkommen, wird das an der Kante als Property gespeichert. Intents können dann eindeutiger identifiziert werden. Für die Lernfunktion istnun ein wenig Mathematik notwendig. Doch keine Angst, wenn Sie die 9. Klasse erfolgreich abgeschlossen haben, wird das für Sie wie ein Länderspiel laufen.
 
Im einfachsten Fall wird das Lernen mit einer Geradengleichung abgebildet. Der Anstieg ist linear.
 
B(t)=k*t+B(0)
 
B(t) - Bestand nach Schritten, k - Änderungsrate
B(0) - Anfangsbestand
 
Um Nachfolgeelemente der Gleichung berechnen zu können, ist die rekursive Darstellung sinnvoll.
B_(t+1)=B_t+k
 
B_(t+1) – Bestand nach t+1 Schritten
 
Bei einer Änderungsrate k=1 ist die Lernfunktion ein einfaches „Hochzählen“. Das kann mit Cypher umgesetzt werden: 
SET r.Count_LichtAnAus = r.Count_LichtAnAus +1
Der Score der Lernfunktion ist in diesem Fall mit der Anzahl gleichzusetzten (Count_LichtAnAus = LineareFunktionScore_LichtAnAus)
 
Um ein Übertraining zu verhindern, wird häufig eine gedämpfte Funktion (z.B. der Logarithmus) verwendet. Werden beim Training sehr viele Sätze mit „im Flur“ bei dem Intent „LichtAnAus“ verwendet, wird diese Phrase mit hoher Wahrscheinlichkeit überwiegend diesen Intent zugeordnet. Andere Intents bei denen „im Flur“ auch vorkommt, aber seltener, wird „im Flur“ trotzdem überwiegend dem Intent „LichtAnAus“ zugeordnet. Um dieses Kräftegleichgewicht besser balancieren zu können, kann deshalb folgende nichtlineare Funktion zum Lernen verwendet werden. 
SET r.LogScore_LichtAnAus = log(r.Count_LichtAnAus+1)
Je nach Bedarf können noch Konstanten hinzugefügt werden, die die Stärke der Dämpfung beeinflussen. Hier spielt die Größe des Trainingssets eine Rolle.
 

Step 4: Lernphase

Zu Beginn der Lernphase sollte unbedingt ein Index erstellt werden und verhindert werden, dass Wörter doppelt im Graph abgespeichert werden
CREATE CONSTRAINT ON (word:Word) ASSERT word.name IS UNIQUE
Fügt man die einzelnen Cypher-Fragmente zusammen, kann mit einer einzigen Query „gelernt“ werden. Im Nachfolgenden wird das Ergebnis einer linearen Funktion und das Ergebnis einer nichtlinearen Funktion als Information an den entsprechenden Kanten geschrieben. Später bei der Bestimmung von den Intents, können beide Propertys verwendet werden.
Lernen des Intents LichtAnAus:
 
//Auswahl eines Satzes
WITH "Schalte das Licht im Flur an." as sentence
//Eliminierung Satzzeichen
WITH reduce(oldSentence=sentence, rmvPunc in [",",";",":",".","!","?"] | replace(oldSentence,rmvPunc,"")) as sentence
//Tokenizer
WITH split(tolower(sentence)," ") as words
//Elimienierung Stoppwörter
WITH [w in words WHERE NOT w IN ["der","die","das","den","dem"]] as words
UNWIND range(0,size(words)-2) as index
//Erstellung der Knoten und Kanten
MERGE (w1:Word {name:words[index]})
MERGE (w2:Word {name:words[index+1]})
MERGE (w1)-[r:NEXT]->(w2)
//Setzen von Initialwerten
ON CREATE SET r = {Count_LichtAnAus: 1.0, Count_EssenBestellung: 0.0, Count_RufeTechniker: 0.0, LogScore_LichtAnAus:0.7, LogScore_EssenBestellung: 0.0, LogScore_RufeTechniker: 0.0}
//lineare Lernfunktion
ON MATCH SET r.Count_LichtAnAus = r.Count_LichtAnAus +1,
//nicht-lineare Lernfunktion mit Verstärkung 2
r.LogScore_LichtAnAus = 2*log((r.Count_LichtAnAus+1))
Beim Lernen der anderen Intents (z.B.: EssenBestellung), ändert sich nur folgender Teil:
...
MERGE (w1)-[r:NEXT]->(w2)

ON CREATE SET r = {Count_LichtAnAus: 0.0, Count_EssenBestellung: 1.0, Count_RufeTechniker: 0.0, LogScore_LichtAnAus: 0.0, LogScore_EssenBestellung: 1.0, r.LogScore_RufeTechniker: 0.0}

ON MATCH SET r.Count_EssenBestellung = r.Count_EssenBestellung +1,
...
In diesem Beispiel soll dieses kleine Trainingsset genügen:
 
LichtAnAus EssenBestellung TechnikerRufen
Schalte das Licht im Flur an Bestelle eine Pizza beim Italianer Rufe einen Techniker an, die Heizung ist kaputt
Mach die Lampe im Wohnzimmer aus Rufe beim Italiener an und bestelle Pizza Salami Meine Herdplatte ist defekt, rufe einen Techniker an
Schalte das Licht in der Küche aus Rufe beim Chinesen an und bestelle gebratene Nudeln Rufe einen Elektriker an, die Spülmaschine ist kaputt
Licht im Flur anschalten Bestelle einen Döner mit Spezialsoße beim Türken Der Ofen ist schon wieder defekt, Techniker anrufen
Schalte alle Lichter im Flur an Ich habe hunger, bestelle was zum Essen Die Lampe ist defekt, rufe einen Elektriker an.

Bestimmung der Intents

Um die einzelnen Intents zu bestimmen, kann man zunächst einen einfachen Algorithmus implementieren, der den Score der umliegenden Kanten eines Wortes addiert und normiert ausgibt. Hier wird der Score der linearen Lernfunktion verwendet.
WITH "Schalte das Licht im Schlafzimmer an" as sentence
WITH reduce(Test=sentence, rmvPunc in [",",";",":",".","!","?"] | replace(Test,rmvPunc,"")) as sentence
WITH split(tolower(sentence)," ") as words
with [w in words WHERE NOT w IN ["der","die","das","den","dem"]] as words
UNWIND words as Test
//Addiere alle Scores der Kanten
Match(n:Word{name:Test})-[r:NEXT]-()with SUM(r.Count_LichtAnAus) as LichtAnAus, SUM(r.Count_EssenBestellung) as EssenBestellung, SUM(r.Count_RufeTechniker) as RufeTechniker
//Normierung
With ((LichtAnAus)/(LichtAnAus + EssenBestellung + RufeTechniker)) as Score_LichtAnAus,
((EssenBestellung)/(LichtAnAus + EssenBestellung + RufeTechniker)) as Score_EssenBestellung,
((RufeTechniker)/(LichtAnAus + EssenBestellung + RufeTechniker)) as Score_RufeTechniker
return Score_LichtAnAus,Score_EssenBestellung,Score_RufeTechniker
Beispielsatz  Score_LichtAnAus Score_EssenBestellung Score_RufeTechniker
Schalte das Licht im Schlafzimmer an 0,64 0,14 0,22
Bestelle eine große Pizza Funghi beim Italiener 0 1.0 0
Rufe einen Techniker an, die Solarzelle auf dem Dach ist defekt 0,04 0,15 0,81
Licht an 0,41 0,24 0,35
 
Schon mal ein tolles Ergebnis oder nicht?
 
Ich muss zugeben, bei „Licht an“ hätte ich auch ein eindeutigeres Ergebnis erwartet. Der Grund hierfür ist, dass das Wort „an“ auch bei „rufe an“ häufig vorkommt, was nichts mit Licht zu tun hat. Um das Ergebnis zu verbessern, ist es sinnvoll die Vernetzung der Wörter innerhalb des Satzes miteinzubeziehen. Hier können ähnliche Strukturen des Satzes im Graph gesucht werden. 
 
Mit dem Beispielsatz „Rufe Techniker an“ ist das folgende Vorgehen notwendig:
 
Bilde Kreuzprodukt-> es entstehen eine Menge von Wortpaaren mit [Startknoten,Zielknoten]:
[rufe,rufe]; [rufe,techniker]; [rufe,an]; 
[techniker,rufe]; [techniker, techniker];[ techniker,an] 
usw.
 
Finde Phrasen der Wortpaare
z.B. [rufe an] findet folgende Phrasen:
„rufe einen techniker an“
„rufe einen elektriker an“
„rufe beim chinesen an“ 
„rufe beim italiener an“
 
Summiere alle Counts
Berechne Score
Score= Counts*Anzahl getroffene Wörter in Phrase
Bsp. die Phrase: „rufe einen elektriker an“ hat 2 getroffene Wörter im Satz (rufe, an)
 
Folgende Query soll dies realisieren
 
WITH "Rufe einen Techniker an, die Solarzelle auf dem Dach ist defekt" as sentence
WITH reduce(Test=sentence, rmvPunc in [",",";",":",".","!","?"] | replace(Test,rmvPunc,"")) as sentence
WITH split(tolower(sentence)," ") as words
with [w in words WHERE NOT w IN ["der","die","das","den","dem"]] as words
UNWIND words as word
UNWIND words as word2
//(1) Kreuzprodukt
MATCH (nodes1:Word{name:word}) 
MATCH (nodes2:Word{name:word2})
//(2) Finde Phrasen mit Tiefe 3
Match path=(nodes1)-[r:NEXT*1..3]->(nodes2)
UNWIND r as rels
//(3)Summiere alle Counts/Scores
with SUM(rels.Count_LichtAnAus) as LichtAnAus, SUM(rels.Count_EssenBestellung) as EssenBestellung, SUM(rels.Count_RufeTechniker) as RufeTechniker,
//gefundene Phrasen
[n in nodes(path) | n.name] as phrases, words
with filter(x IN phrases WHERE x in words) as StringPhrases, LichtAnAus, EssenBestellung, RufeTechniker
//(4)Berechnung: Counts*Anzahl getroffene Wörter in Phrase
with sum(LichtAnAus*size(StringPhrases))as LichtAnAus,sum(EssenBestellung*size(StringPhrases))as EssenBestellung,sum(RufeTechniker*size(StringPhrases))as RufeTechniker
return((LichtAnAus)/(LichtAnAus + EssenBestellung + RufeTechniker)) as Score_LichtAnAus,
((EssenBestellung)/(LichtAnAus + EssenBestellung + RufeTechniker)) as Score_EssenBestellung,
((RufeTechniker)/(LichtAnAus + EssenBestellung + RufeTechniker)) as Score_RufeTechniker

 

Beispielsatz Score_LichtAnAus Score_EssenBestellung Score_RufeTechniker
Schalte das Licht im Schlafzimmer an 1,0 0 0
Bestelle eine große Pizza Funghi beim Italiener 0 1,0 0
Rufe einen Techniker an, die Solarzelle auf dem Dach ist defekt 0 0,8 0,92
Licht an 1,0 0 0

„Licht an“ wird eindeutig erkannt, weil man nur die passende Phrase „schalte licht im flur an“ gefunden hat.

Um die Intents noch besser zu erkennen, kann auch eine Kombination der zwei gezeigten Cypher-Querys stattfinden. 

Bestimmung der Entities

Ich werde nun einen kleinen Einblick geben, wie man auch Entities (Personen, Orte, …) aus verschiedenen Sätze extrahieren kann. Im folgenden Beispiel ist es das Ziel, den Ort zu bestimmen.
 
„Schalte das Licht im Flur aus“
Ziel: Bestimmung des Ortes „Flur“
 
Optimal eignen sich hierbei Labels. Je mehr Orte mit dem Label: “Location“ versehen werden, desto intelligenter wird der Graph. Mit folgender Query, kann der Graph trainiert werden:
MATCH(n:Word{name:"flur"}) Set n:Location
Orte können nun wie folgt identifiziert werden:
WITH "Schalte das Licht im Flur aus" as sentence
WITH reduce(Test=sentence, rmvPunc in [",",";",":",".","!","?"] | replace(Test,rmvPunc,"")) as sentence
WITH split(tolower(sentence)," ") as words
with [w in words WHERE NOT w IN ["der","die","das","den","dem"]] as words
UNWIND words as Stringwords
Match(n:Word{name:Stringwords}) return n, labels(n)

Um auch unbekannte Orte zu identifizieren, die nicht das Label „Location“ im Graph haben, können benachbarte Wörter mit einbezogen werden (oder zusätzlich auch Nachbarn der benachbarten Wörter). Beim Satz: „Schalte das Licht im Badezimmer aus“ können die Wörter „im“ und „aus“ verwendet werden, um mögliche Labels in der Umgebung zu finden.
 
 
 
Anzahl der verbundenen Labels bei „im“
Optional MATCH (v:Word{name:"im"})-[:NEXT]->(w:Word) return labels(w) as afterLabels, Count(w)
Order by Count(w)
Anzahl der verbundenen Labels bei „aus“
Optional MATCH (v:Word{name:"aus"})<-[:NEXT]-(w:Word) return labels(w) as beforeLabels, Count(w)
Order by Count(w)
Gibt es Labels wie “Food“ können diese auch vorher gefiltert werden, nach der Erkennung des Intents „LichtAnAus“.
 
Doppeldeutige Wörter wie „Bank“ können mit mehreren Labels ausgestattet werden. Hier ist es jedoch sinnvoll, den Kanten eine zusätzliche Information zu geben, dass im nachfolgenden Wort z.B. eine „Sitzgelegenheit“ kommt. So können später die Entities eindeutig identifiziert werden. Der Satz „ich sitze auf der Bank“ kann im Graph wie folgt gespeichert werden.
 
 
 
Zusätzlich können auch Regexe definiert werden, um Entities wie Uhrzeiten und spezielle Zeichenketten einfacher zu erkennen.

Fazit

Es wurde gezeigt, dass mit relativ wenig Aufwand, gute Ergebnisse des natürlichen Sprachverstehens bei kleinen Trainingssets mit Neo4j zu erzielen sind. Dieser Blogbeitrag soll Ihnen dabei helfen, Ihren eigenen NLP-Service zu entwickeln. Dieser kann auf Ihren Bedürfnissen beliebig mit Neo4j angepasst werden. Optimal eignen sich kleinere Anwendungsfälle. Wer jedoch sehr intelligente Algorithmen mit geringer Fehlerqoute benötigt, sollte auf eine Eigenentwicklung verzichten. Ich rate nur Experten der Computerlinguistik diesen Versuch zu wagen. Wer in großen Unternehmen ein NLP-Service einführen will, dieser ausgiebig getestet wurde und wenig Probleme verursacht, kann beispielsweise auf die Cloud Natural Language API von Google oder LUIS von Microsoft zurückgreifen.
 

Neuen Kommentar schreiben