Distributionelle Semantik

Die lexikalische Semantik befasst sich mit der Bedeutung von Wörtern. Statt uns die fast philosophische Frage zu stellen, was die Bedeutung eines Wortes genau ist und diese irgendwie zu kodieren, versuchen wir oft, die Bedeutung eines Wortes durch Beziehungen zu anderen Wörern zu umschreiben. Vorallem Synonyme sind hier sehr hilfreich. Wenn wir mehrere Synonyme, aber vielleicht auch Antonyme und Hypernyme eines Wortes kennen, wird die Bedeutung schnell klar.

Bereits am Anfang des 20. Jahrhunderts bemerkte Wittgenstein, dass die Verwendung eines Wortes sehr viel über seine Semantik verrät. De Saussure präzisiert das mit der Unterscheidung zwischen syntagmatische und paradigmatische Beziehungen zwischen Wörter. Wörter in syntagmatischen Beziehungen, kommen oft zusammen vor, wie Messer und schneiden. Wörter in paradigmatischen Beziehungen kommen in den gleichen Kontexten vor und ähneln sich in ihren syntaktischen und semantischen Eigenschaften. In den fünfziger Jahre wurde diese Idee aufgegriffen, der englische Sprachwissenschaftler John Rubert Firth formulierte die sogenannte Distributionalitätshypothese, die sagt, dass die Bedeutung eines Wortes durch eine Statistik über den Kontexten, in denen es vorkommt, erfasst werden kann.

Erste Implementierungen, die versuchen die Semantik eines Wortes mit einem Merkmalsvektor, der aggregierte Kontexte darstellt, zu erfassen, gibt es ab etwa von Rubenstein und Goodenough (1965), Ruge und Schawarz (1990), Crouch (1990), Crouch und Yang (1992), Grefenstette (1992) und anderen.

Warnung

Das Ausführen der Trainingsphasenin diesem Notebook dauert an einigen Stellen sehr lang! Wenn Sie alles nachbauen wollen, brauchen Sie etwas Geduld. In diesem Tutorial wird ausserdem etwas mehr Vorkenntnisse vorausgesetzt als in den anderen Tutorials.

Daten

Um einige Verfahren testen zu können brauchen wir (allgemeinsprachliche) Texte. Einerseits möchten wir möglichst viel Text haben, da Verfahren der distributionellen Semantik mit mehr Daten immer besser werden. Andererseits möchten wir nicht zu viele Texte verarbeiten, um die Verarbeitungs- und Wartezeit nicht ausufern zu lassen.

Auf der Webseite der Abteilung für Computerlinguistik der Universität Leipzig stehen einige geeignete Textkorpora. Von der Seite http://wortschatz.uni-leipzig.de/en/download/ laden wir die Datei deu_wikipedia_2016_300K-sentences herunter.

Einlesen der Daten

Wir lesen die Sätze ein und führen sofort eine Tokenisierung aus. Ggf. könnten wir die Wörter auch Lemmatisieren oder Groß- und Kleinschreibung normalisieren. Wir belassen die Wörter jetzt aber so wie wir sie finden.

In [1]:
import codecs
import nltk

sentences = []

quelle = codecs.open('Corpora/deu_wikipedia_2016_300K/deu_wikipedia_2016_300K-sentences.txt','r','utf8')
for line in quelle:
    nr,satz = line.split('\t')
    sentences.append(nltk.word_tokenize(satz.strip()))

und testen mal einen willkürlichen Satz:

In [2]:
sentences[447]
Out[2]:
['1819',
 'wurde',
 'daher',
 'ein',
 'neues',
 '„',
 'Vereidigungsbuch',
 '“',
 'angelegt',
 '.']

Vektoren von Kookkurrenzwerten

Wir schreiben eine Funktion, die für eine gegebene Liste von Wörtern einen Vektor mit Kookkurrenzzahlen für eine Reihe anderer Wörter berechnet. Als Liste von Wörtern, mit denen wir die Kookkurrenz berechnen, nehmen wir alle Wörter, die eine festgelegte Mindesthäufigkeit übersteigen. Kookkurrenzen mit seltenen Wörtern werden kaum etwas zum Vergleich zwischen Kontextvektoren beitragen. Ein weiterer Parameter, der festgelegt werden muss, ist die maximale Entfernung zwischen zwei Wörtern um als Kookkurrenz gezählt werden zu können. Wir gehen hier Satzweise vor. Das heißt, dass das letzte Wort eines Satzes und das erste Wort des nächsten Satzes nie als Kookkurrenz gezählt werden. Oft werden bei solchen Verfahren Satzgrenzen nicht berücksichtigt. Ob dies große Unterschiede mit sich bringt ist nicht klar. Ein weiteres Detail, das zu beachten ist, ist dass, wir zuerst die Fenstergröße für die Kookkurrenz berechnen, und dann die relevante Wörter in dem Fenster bestimmen. Alternativ kann man zuerst alle irrelevante Wörter entfernen und dann die Wörter innerhalb vom Fenster bestimmen.

Im Allgemeinen kann ein größeres Fenster ein zu kleines Korpus kompensieren. Außerdem erfasst man mit einem kleineren Fenster mehr syntaktische Eigenschaften eines Wortes, während ein größeres Fenster weitläufigere semantische Beziehungen berücksichtigt.

Wenn alle Kookkurrenzen berechnet sind, normalisieren wir die Länge der Vektoren.

In [5]:
from scipy import sparse
from collections import Counter
import numpy as np

def mag(x):
    return np.sqrt(x.dot(x))


def makeCV(words,sentences, window = 2, minfreq = 10):
    freq = Counter()
    for s in sentences:
        freq.update(s)
        
    
    context_words = [w for w,f in freq.items() if f > minfreq]
    for w in words:
        if w not in context_words:
            context_words.append(w)
    dim = len(context_words)
    cw2nr = {}
    for i in range(dim):
        cw = context_words[i]
        cw2nr[cw] = i
              
    w2nr = {}
    for i in range(len(words)):
        w = words[i]
        w2nr[w] = i
    
    
    matrix = sparse.lil_matrix((len(words),dim))
    
    n = 0
    for s in sentences:
        n+=1
        i_s = [cw2nr.get(w,-1) for w in s]
        for i in range(len(s)):
            w = s[i]
            if w in words:
                i_w = w2nr[w]
                for j in range(max(0,i-window),min(i+window+1,len(s))):
                    if i != j:
                        i_cw = i_s[j]
                        if i_cw > 0:
                            matrix[i_w,i_cw] += 1
          
    wordvectors = {}
    for w,i_w in w2nr.items():
        v = matrix[i_w].toarray()[0]
        wordvectors[w] = v/mag(v)
    return wordvectors

Wir berechnen die Vektoren für einige wenige Wörter:

In [6]:
vectors_count =  makeCV(['Kloster','Kirche','Garten','Haus','Hof','Schweden','Deutschland','betrachten','anschauen','beobachten'],sentences,window=2)

Da die Vektoren die gleiche Länge haben, können wir das innere Produkt, das also identisch mit dem Kosinus ist, zum vergleichen nutzen:

In [7]:
# cosinus(vectors['Kirche'],vectors['Kloster'])
print(vectors_count['Schweden'].dot(vectors_count['Deutschland']))
print(vectors_count['Schweden'].dot(vectors_count['Hof']))
0.8827709543096601
0.702106425947288

Wenn wir den Kosinus zwischen einem Wort und allen anderen Wörtern im Vokabular berechnen, können wir die ähnlichsten Wörter bestimmen. Wir nutzen hierzu eine geordnete Liste. Da diese immer aufsteigend sortiert ist, sortieren wir nach $-1 \cdot cosinus$.

In [8]:
import bisect 

def most_similar(word,vectors,n):
    best = []
    vec_w = vectors[word]
    for z in vectors:
        if z == word:
            continue
        sim = vec_w.dot(vectors[z])
        #best.append((z,sim))
        if len(best) > n:
            opt,_ = best[-1]
        else:
            opt = 0
        if -sim < opt:
            bisect.insort_left(best,(-sim,z))
            best = best[:n]
        
    #best = sorted(best, key=lambda x: x[1], reverse = True)[:n]
    return [(w,-sim) for (sim,w) in best]
    
In [9]:
most_similar('Garten',vectors_count,3)
Out[9]:
[('Hof', 0.8130680728909148),
 ('Haus', 0.664617096252052),
 ('Schweden', 0.6388171089882518)]

Interessanter werden diese Listen natürlich, wenn wir Vektoren für mehr Wörter berechnen. Hierfür wählen wir alle Wörter aus, die nicht zu selten und nicht zu häufig sind, also die Wörter, die inhaltlich interessant sind.

In [12]:
wortfrequenz = Counter()

for satz in sentences:
    wortfrequenz.update(satz)

vocabulary  = [w for w,f in wortfrequenz.items() if 50 < f < 2000]
vocabulary_size = len(vocabulary)
print(vocabulary_size)
7852
In [13]:
vectors_count = makeCV(vocabulary,sentences,window=2)
In [14]:
most_similar('Garten',vectors_count,10)
Out[14]:
[('Park', 0.930302724001756),
 ('Saal', 0.8713290722481782),
 ('Tempel', 0.8648406935911824),
 ('Bezirk', 0.8631281311361956),
 ('Sturm', 0.8620724034396585),
 ('Keller', 0.858317906806122),
 ('Raum', 0.8572571532271944),
 ('Wald', 0.8549354330124461),
 ('Kreis', 0.8549228454240734),
 ('Sinn', 0.846418171910536)]
In [15]:
most_similar('betrachten',vectors_count,10)
Out[15]:
[('verstehen', 0.9585602463943913),
 ('ermitteln', 0.9577321881251438),
 ('schaffen', 0.9575996519992644),
 ('bauen', 0.9566353415343977),
 ('beobachten', 0.9549111806749297),
 ('verwenden', 0.9537790612894577),
 ('erhöhen', 0.9526877411161037),
 ('bringen', 0.9525533613296538),
 ('retten', 0.9478641686123344),
 ('kämpfen', 0.947498120959553)]
In [16]:
most_similar('Schweden',vectors_count,10)
Out[16]:
[('Ungarn', 0.9462175999010392),
 ('Polen', 0.9379372993961502),
 ('Frankreich', 0.9353772897649918),
 ('Australien', 0.9310094388128083),
 ('Spanien', 0.93014008593448),
 ('Russland', 0.9239015030110829),
 ('Italien', 0.9229256210189929),
 ('England', 0.922348217698481),
 ('Ägypten', 0.9189920519494904),
 ('Kanada', 0.9183081684907252)]
In [17]:
most_similar('Direktor',vectors_count,10)
Out[17]:
[('Leiter', 0.9482467477877202),
 ('Chef', 0.9404963751501492),
 ('Kommandeur', 0.9368719576058703),
 ('Vorsitzender', 0.9341183267714239),
 ('Präsident', 0.923324253533616),
 ('Mitglied', 0.9104529525364085),
 ('Vizepräsident', 0.8922921792779437),
 ('Vorstandsmitglied', 0.8728248376457493),
 ('Sekretär', 0.8697536816828365),
 ('Kommandant', 0.8598231918685795)]

Evaluieren

Die Beispiele sehen zwar ganz nett aus, aber wie gut sind unsere Vektoren wirklich? Es gibt eine Reihe von Tests, die man durchführen kann, um die Qualität der Vektoren zu überprüfen. Eine Art von Test ist es, die berechnete Ähnlichkeit, zwischen zwei Wörtern zu vergleichen mit der Ähnlichkeit, die Probanden für Wortpaare angegeben haben. Um die Ähnlichkeit zu prüfen können wir einfach die Korrelation nutzen.

Ein Datensatz mit Ähnlichkeitseinschätzungen für Deutsche Wortpaare ist der sogenannte Gur350 Datensatz, der von der Arbeitsgruppe von Prof. Dr. Iryna Gurevych an der TU Darmstadt zusammengestellt wurde. Weitere Informationen und ein Link für den Download finden Sie hier: https://www.informatik.tu-darmstadt.de/ukp/research_6/data/semantic_relatedness/german_relatedness_datasets/index.en.jsp

Die Daten geben nicht eine Ähnllichkeit zwischen den Wörtern sondern eine Verwandschaft, was nicht ganz das Gleiche ist. Für die Evaluierung nutzen wir nur die Wortpaare, für die beide Worte in unserem Vokabular enthalten sind.

In [22]:
import math
testfile = codecs.open('Corpora/wortpaare350.gold.pos.txt','r','utf8')
testfile.readline()

testdata = []
for line in testfile:
    w1,w2,sim,p1,p2 = line.split(':')
    if w1 in vocabulary and w2 in vocabulary:
        testdata.append((w1,w2,float(sim)))
    
def evaluate(data,vectors):
    gold = []
    predicted = []
    for v,w,sim in data:
        pred = vectors[v].dot(vectors[w])

        gold.append(sim)
        predicted.append(pred)
        #print(v,w,pred,sim,sep = '\t')
        
    
    av_p = sum(predicted)/len(predicted)
    av_g = sum(gold)/len(gold)
    
    cov = 0
    var_g = 0
    var_p = 0
    for s,t in zip(gold,predicted):
        cov += (s-av_g) * (t-av_p)
        var_g += (s-av_g) * (s-av_g)
        var_p += (t-av_p) * (t-av_p)
        
    return cov / math.sqrt(var_g*var_p)
In [23]:
evaluate(testdata,vectors_count)
Out[23]:
0.15026487881136355

Trotz der gut aussehenden Listen, die wir weiter oben generiert haben, sehen wir, dass die berechnete Ähnlichkeiten mit den Ähnlichkeitsurteilen nur sehr schwach korrellieren.

Wir nehmen noch einen zweiten Datensatz zum testen. Von http://textmining.wp.hs-hannover.de/datasets.html können Sie eine Liste STW German Synonyms herunterladen. Diese Liste enthält 132 Wortpaare. In der Hälfte der Fälle sind beide Wörter alternative Benennungen für das gleiche Konzept in dem Standard Thesaurus Wirtschaft. In den anderen Fällen wurden die Begriffe willkürlich zusammengesucht. Die positive Fälle sind also Synonyme oder stark verwandte Wörter, während die negeative Fälle semantisch unähnliche sein sollten. Wenn wir jetzt für jedes Paar die Ähnlichkeit berechen und die Liste absteigend nach Ähnlichkeit sortieren, sollten die positive Paare oben stehen und die negative unten. Die Area under the ROC-Curve (AUC) ist ein Maß, das genau angibt inwiefern das auch tatsächlich der Fall ist.

Wir lesen die Liste ein und schreiben eine zweite Evaluierungsfunktion.

In [99]:
import numpy as np
from sklearn import metrics

stw = codecs.open('Corpora/STW-Syn.txt','r','utf8')


testdata_stw = []
for line in stw:
    w1,w2,sim = line.strip().split('\t')
    if w1 in vocabulary and w2 in vocabulary:
        testdata_stw.append((w1,w2,sim))
    
def evaluate_bin(data,vectors):
    labels = []
    predictions = []
    for v,w,sim in data:
        pred = vectors[v].dot(vectors[w])
        labels.append(sim)
        predictions.append(pred)
    fpr, tpr, thresholds = metrics.roc_curve(labels, predictions, pos_label='+')
    return metrics.auc(fpr, tpr)   
In [100]:
evaluate_bin(testdata_stw,vectors_count)
Out[100]:
0.5672635445362718

Das Ergebnis ist ähnlich, wie beim ersten Datensatz: wir sind etwas besser als man durch Zufall erwarten würde, aber nicht sehr viel.

Etwas avancierter

Unser erster Versuch bietet noch verschiedene Optimierungsmöglichkeiten. Zunächst nutzen wir nicht die einfache Kookkurrenzhäufigkeiten sondern die Positive Pointwise Mutual Information (PPMI). Die Pointwise Mutual Information zwischen zwei Wörter a und b ist im Prinzip das Verhältnis zwischen der tatsächlichen Wahrscheinlichkeit, dass sie zusammen vorkommen, und die erwartete Wahrscheinlicheit, dass sie zusammen vorkommen, wenn ihre Vorkommen unabhängig voneinander wären. Wir definieren $pmi(a,b) = log(\frac{p(ab)}{p(a)p(b)})$. Die ppmi(a,b) ist nun die pmi(a,b), wenn dieser Wert positiv ist, und sonst 0. Die Benutzung der ppmi beruht auf der Annahme, dass nur die Tatsache, dass Wörter häufiger als erwartet zusammen vorkommen interessant ist, während geringere Wahrscheinlichkeiten vermutlich eher auf Zufall beruhen oder jedenfalls nichts interesantes über Wortpaare aussagen.

Ein Problem unseres naiven Ansatzes war, dass die Vektoren extrem lang sind. Die Vektoren brauchen nicht nur sehr viel Speicheplatz. Viele der Dimensionen enthalten auch wenig oder nur redundante Informationen. Dieses Problem kann durch die Anwendung eines gängigen Dimensionsreduktionsverfahren gelöst werden. Hierunten werden wir hierfür Singular Value Decomposition nutzen und anschließend die wichtigste 100 Dimensionen nutzen.

In [24]:
from sklearn.decomposition import TruncatedSVD
import math

def makeCV_SVD(words,sentences, window = 2, minfreq = 10, size =256):
    N = 0
    freq = Counter()
    for s in sentences:
        N += len(s)
        freq.update(s)
    
    context_words = [w for w,f in freq.items() if f > minfreq]
    for w in words:
        if w not in context_words:
            context_words.append(w)
    dim = len(context_words)
    cw2nr = {}
    for i in range(dim):
        cw = context_words[i]
        cw2nr[cw] = i
              
    w2nr = {}
    for i in range(len(words)):
        w = words[i]
        w2nr[w] = i
    
    
    matrix = sparse.lil_matrix((len(words),dim))
    
    n = 0
    for s in sentences:
        n+=1
        i_s = [cw2nr.get(w,-1) for w in s]
        for i in range(len(s)):
            w = s[i]
            if w in words:
                i_w = w2nr[w]
                for j in range(max(0,i-window),min(i+window+1,len(s))):
                    if i != j:
                        i_cw = i_s[j]
                        if i_cw > 0:
                            matrix[i_w,i_cw] += 1
     
    
    probabilities = []
    for cw in context_words:
        probabilities.append(freq[cw]/N)
        
    N2 = matrix.sum()/2
    (rows,cols) = matrix.nonzero()
    for i_w,i_cw in zip(rows,cols):
        p_w = probabilities[i_w]
        p_cw = probabilities[i_cw]
        p = matrix[i_w,i_cw]/N2
        ppmi = max(0,math.log(p/(p_w * p_cw * 2 * window) ))
        matrix[i_w,i_cw] = ppmi

    svd = TruncatedSVD(n_components=size)
    svd.fit(matrix)
    matrix = svd.transform(matrix)
    wordvectors = {}
    for w,i_w in w2nr.items():
        v = matrix[i_w] 
        wordvectors[w] = v/mag(v)
    return wordvectors
In [26]:
vectors_SVD = makeCV_SVD(vocabulary,sentences,window=2,size=100)
In [27]:
most_similar('Garten',vectors_SVD,10)
Out[27]:
[('Ensemble', 0.8878522649080942),
 ('Tempel', 0.8857947478857268),
 ('Brunnen', 0.8784445934307104),
 ('Saal', 0.8742343359547232),
 ('Innenhof', 0.8709319133786978),
 ('Altar', 0.8678053877789418),
 ('Friedhof', 0.8674765900658197),
 ('Hafen', 0.8606303009565751),
 ('Palast', 0.8589551792924144),
 ('Ring', 0.8574605568407047)]
In [28]:
most_similar('betrachten',vectors_SVD,10)
Out[28]:
[('handeln', 0.8650859302423339),
 ('angewiesen', 0.8459365136727667),
 ('betrachtet', 0.8445539699129608),
 ('vielleicht', 0.8422021384316517),
 ('verlieren', 0.8411273230797555),
 ('bezeichnen', 0.8404027396630086),
 ('erinnern', 0.838405610302819),
 ('wichtig', 0.8375990974296426),
 ('erfüllt', 0.8365684507034117),
 ('behandelt', 0.8356506496763467)]
In [29]:
most_similar('Schweden',vectors_SVD,10)
Out[29]:
[('Ungarn', 0.8996445038236395),
 ('Russland', 0.8958826237622023),
 ('Südafrika', 0.8928464564401226),
 ('Brasilien', 0.8885098260479632),
 ('Kanada', 0.8771843797383817),
 ('Italien', 0.8752369023840376),
 ('Rumänien', 0.8731898558434427),
 ('England', 0.871881570353987),
 ('Polen', 0.8710878133904942),
 ('Australien', 0.8697441649540956)]
In [31]:
most_similar('Park',vectors_SVD,10)
Out[31]:
[('Hospital', 0.8638859079398314),
 ('Bay', 0.861292753073026),
 ('River', 0.8452455856676708),
 ('Lake', 0.8451312045887227),
 ('Valley', 0.8247861488986864),
 ('Grand', 0.8229613945827218),
 ('Beach', 0.8227172286074342),
 ('Airport', 0.816871126312216),
 ('Parks', 0.816620656656485),
 ('West', 0.8164065864428915)]

Wir sehen jetzt, dass Park hier nicht mehr als Synonym von Garten interpretiert wird, sondern vielmehr als Bestandteil von Englischen Ortsbezeichnungen wahrgenommen wird. Der Kontext 'Englische Wörter' hat offenbar an Bedeutung gewonnen.

In [32]:
evaluate(testdata,vectors_SVD)
Out[32]:
0.4110309994674306
In [102]:
evaluate_bin(testdata_stw,vectors_SVD)
Out[102]:
0.7874196510560149

Die Optimierung war offenbar erfolgreich: sowohl die Korrellation im ersten Test als die AUCH im zweiten Test sind deutlich größer geworden. Überblicke über die Kombinationen von Parametern, Trainingsmengen usw., die zu optimalen Ergebnissen führen geben beispielsweise Bullinaria und Levy (2007), Mohammad und Hirst (2012), Bullinaria und Levy (2012) und Kiela und Clark (2014)

Random Indexing

Eine relativ wenig beachtete Alternative bietet Random Indexing (Karlgren und Sahlgren, 2004). Beim Random Indexing wird jedes Wort zunächst durch einen relativ kurzer Vektor dargestellt. Fast alle Werte in diesem Vektor sind 0 und einige zufällige Dimensionen bekommen den Wert 1 oder -1. Jedes Wort bekommt hiermit einen zufällig gewählten Fingeabdruck. Wir nennen diese Vektoren die Randomvektoren.

Um einen sinnvollen Wortvektor für ein Wort $w$ zu berechnen, addieren wir alle Randomvektoren der Wörter, die im Kontext von $w$ vorkommen.

Der Vorteil des Random Indexings ist, dass wir nicht erst komplette Kokkurrenzmatrizen aufgebaut haben müssen, sondern dass wir vom Anfang an kurze Wortvektoren haben und diese sukzessive aufbauen können.

In [35]:
import numpy
import random 

def makeCVri(words,sentences, window = 2, minfreq = 10, size =256):
    freq = Counter()
    for s in sentences:
        freq.update(s)
        
    
    context_words = [w for w,f in freq.items() if f > minfreq]
    for w in words:
        if w not in context_words:
            context_words.append(w)

    randomvectors = {}
    for cw in context_words:
        v = numpy.zeros(shape=(size,))
        for i in range(3):
            r = random.randrange(0,size)
            v[r] = 1
        for i in range(3):
            r = random.randrange(0,size)
            v[r] = -1
        randomvectors[cw] = v #sparse.csr_matrix(v)
        
    wordvectors = {}
    for w in words:
        v = numpy.zeros(shape=(size,))
        wordvectors[w] = v

    for s in sentences:
        for i in range(len(s)):
            w = s[i]
            if w in words:
                for j in range(max(0,i-window),min(i+window+1,len(s))):
                    if i != j:
                        cw = s[j]
                        if cw in context_words:
                            wordvectors[w] += randomvectors[cw]
                            
    for w in wordvectors:
        wordvectors[w] = wordvectors[w]/mag(wordvectors[w])

    return wordvectors
In [36]:
vectors_ri = makeCVri(vocabulary,sentences,window=2,size=100)
In [37]:
most_similar('Garten',vectors_ri,10)
Out[37]:
[('Park', 0.9251486545273567),
 ('Tempel', 0.8840289806788559),
 ('Körper', 0.8797006013022292),
 ('Sturm', 0.8773844689209106),
 ('Chor', 0.8748913680960568),
 ('Zustand', 0.8674026796536071),
 ('Club', 0.8662295632398277),
 ('Bezirk', 0.8620383539095523),
 ('steht', 0.860587500575702),
 ('Untergrund', 0.8597894470670584)]
In [38]:
most_similar('betrachten',vectors_ri,10)
Out[38]:
[('bauen', 0.9669048140396387),
 ('ermitteln', 0.9580885374633131),
 ('verwenden', 0.9568905328324356),
 ('verstehen', 0.95451103196656),
 ('bringen', 0.9521580731725495),
 ('retten', 0.9517634057597459),
 ('erhöhen', 0.9511300963490905),
 ('überwinden', 0.9500627814617033),
 ('schaffen', 0.9497053196773892),
 ('machen', 0.9489255878822447)]
In [39]:
most_similar('Schweden',vectors_ri,10)
Out[39]:
[('Ungarn', 0.9458181556480092),
 ('Polen', 0.9350010462134569),
 ('Australien', 0.9272200499254896),
 ('England', 0.926110788817783),
 ('Spanien', 0.9241172311180933),
 ('Russland', 0.9230486820835657),
 ('Ägypten', 0.9228018550287606),
 ('Italien', 0.9214963962152157),
 ('Frankreich', 0.9180932165210997),
 ('Kanada', 0.9180721574875442)]
In [40]:
most_similar('Direktor',vectors_ri,10)
Out[40]:
[('Chef', 0.9533831080234763),
 ('Leiter', 0.9470365474917021),
 ('Kommandeur', 0.941413740272816),
 ('Vorsitzender', 0.937730187189276),
 ('Präsident', 0.9270052296383373),
 ('Mitglied', 0.9147367545026008),
 ('Sekretär', 0.8927093604596926),
 ('Vizepräsident', 0.890631908943808),
 ('Vorstandsmitglied', 0.8727657114808953),
 ('Sprecher', 0.871227477405446)]
In [41]:
most_similar('Park',vectors_ri,10)
Out[41]:
[('Garten', 0.9251486545273567),
 ('Chor', 0.9000957305331548),
 ('Keller', 0.8761594758331899),
 ('Tempel', 0.8736239282944893),
 ('Kreis', 0.8662039550308922),
 ('Stil', 0.8656218373009241),
 ('Stadtteil', 0.8611811324499289),
 ('Saal', 0.8585893568556109),
 ('Bezirk', 0.8581746205421625),
 ('Nationalpark', 0.8541917440227763)]
In [42]:
evaluate(testdata,vectors_ri)
Out[42]:
0.10343648473926988
In [103]:
evaluate_bin(testdata_stw,vectors_ri)
Out[103]:
0.5608356290174472

Die Ergebnisse sind, jedenfalls im ersten Test, schlechter als die, die wir mit den anderen Vektoren erzielt haben. Im zweiten Test sind de Ergebnisse vergleichbar mit denen aus den ersten Vektoren ohne PPMI und SVD. Wir haben es also geschafft ohne viel Qualitätsverlust mit wesentlich kürzeren Vektoren zu arbeiten, aber haben nicht die Vorteile der PPMI Gewichtung und der SVD. Da wir mit Random Indexing leichter größere Korpora verarbeiten können als mit den vorherigen Verfahren, kann Random Indexing trotzdem sinnvolle Ergebnisse liefern.

Skip Gram

Die bisherige Versuche haben alle das Problem, dass jede Dimension beim Vergleich von zwei Vektoren genau so stark berücksichtigt wird. Dabei kann es aber sein, dass manche Dimensionen sehr wichtig sind um Wörter voneinander zu unterscheiden, während andere vielleicht für fast alle Wörter gleich sind. Eine geniale Idee, dies zu verbessern stammt von Mikolov et al. (2013) und wurde von Google patentiert. Jedes Wort wird zunächst durch einen kurzen (einige dutzende bis einige hunder Dimensionen) zufällig initialisierten Vektor dargestellt. Jetzt wird ein Classifier trainiert, der aus einem Wort ein Wort im Kontext vorhersagen kann (oder andersherum). In jedem Schritt werden die Werte der Wortvektoren ein wenig angepasst, damit diese Vorhersage besser klappt. Wenn wir ausgehend von einem Wort versuchen die Kontextwörter vorherzusagen, nennen wir das Model ein Skip Gram Model. Wenn wir versuchen aus den Kontextwörter das Zielwort vorherzusagen wird das Model ein Continuous Bag of Words genannt.

Um dieses aufwändige Verfahren zu implementieren eignen sich künstliche neuronale Netze besonders gut. Wir implementieren hier die Grundidee mit der Keras-Bibliothek für Deep Learning.

Damit die Rechenzeit einigermaßen handhabbar bleibt, berücksichtigen wir für den Kontext nur die Wörter aus unserem Vokabular. Wir schließen also sowohl seltene Wörter als auch hochfrequente Wörter aus. Bei den word2vec-Verfahren berechnen wir Wortvektoren für alle Wörter, die wir irgendwie berücksichtigen. Wir unterscheiden also nicht zwischen Zielwörter und Kontextwörter, wie in den vorherigen Verfahren.

Die Archirektur unseres Netzes besteht aus drei Schichten: in der ersten Schicht haben wir für jedes Wort genau ein Knoten. In dieser Schicht wird immer ein Knoten aktiviert. Unser Vokabular muss also vorher bekannt sein und kann nicht erweitert werden. Die zweite Schicht hat genau so viele Konten, wie unsere Wortvektoren Dimensionen haben sollen. Für jedes Wort, bzw. Knoten in der ersten Schicht werden Gewichte für jeden Knoten in dieser zweiten Schicht gelernt. Diese Gewichte sind genau die Werte, die wir später für die Darstellung der Wörter verwenden können. Keras bietet einen speziellen Schichttyp um diese mittlere Schicht samt der Aktivierung durch einen einzelnen Knoten im ersten Schicht an: keras.layers.Embedding.

Die letzte Schicht hat wieder so viele Knoten wie es Wörter gibt. Die Gewichte der Knoten geben hier, für ein gewähltes Ausgangswort, eine Art Wahrscheinlichkeit, dass das Wort das mit einem Knoten in dieser Schicht korrespondiert im Kontext vorkommt. Keras hat für diesen Fall eine spezielle Loss-Funktion (eine Funktion, die berechnet, wie groß der Unterschied zwischen dem aktuellen und gewünschten Ergebnis ist). Als Ergebnis brauchen wir lediglich die Nummer des Knotens, der die höchste Wahrscheinlichkeit haben soll, anzugeben.

Da Wörter jetzt zu durchnummerierten Knoten gehören, geben wir allen Wörtern im Vokabular eine Nummer und erstellen eine Liste von Paare von Wortnummern: jeweils die Nummer eines Wortes als Eingabe und die Nummer eines Kontextwortes als Ziel.

Wir müssen die Daten jetzt mehrfach durch das Netz schicken, damit wir sinnvolle Gewichte bekommen. Wir brechen das hier nach 5 Durchgänge ('Epochen') ab. Da die Ergebnisse immer noch besser werden, sollten wir eigentlich noch einige weitere Durchgänge machen.

In [44]:
embedding_size = 100
window_size = 2
In [46]:
word2id = {w:vocabulary.index(w) for w in vocabulary}

def make_skipgram_data(sents,window):
    x = []
    y = []
    
    for sent in sents:
        for i in range(len(sent)):
            w = sent[i]
            if w not in word2id:
                continue
            for j in range(max(0,i-window),min(len(sent),i+window+1)):
                if i == j:
                    continue
                cw = sent[j]
                if cw not in word2id:
                    continue
                x.append(word2id[w])
                y.append(word2id[cw])
    return x,y
            
sgdata, sglabels = make_skipgram_data(sentences,window_size)
In [47]:
import tensorflow as tf 


def make_vectors_sg():
    model = tf.keras.Sequential([
        tf.keras.layers.Embedding(vocabulary_size, embedding_size,name = "embedding"),
        tf.keras.layers.Dense(vocabulary_size, activation='softmax'), 
    ])

    model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')
    model.fit(sgdata, sglabels, epochs=5)
    
    embed_layer = model.get_layer(name = "embedding")
    id_embeddings = embed_layer.get_weights()[0]

    word_embeddings = {}
    for word in word2id:
        wid = word2id[word]
        vect = id_embeddings[wid]
        word_embeddings[word] = vect /mag(vect)
    return word_embeddings
In [48]:
vectors_sg = make_vectors_sg()
Train on 1646048 samples
Epoch 1/5
1646048/1646048 [==============================] - 1218s 740us/sample - loss: 7.7819
Epoch 2/5
1646048/1646048 [==============================] - 1234s 750us/sample - loss: 7.2135
Epoch 3/5
1646048/1646048 [==============================] - 1237s 752us/sample - loss: 7.0668
Epoch 4/5
1646048/1646048 [==============================] - 1185s 720us/sample - loss: 7.0029
Epoch 5/5
1646048/1646048 [==============================] - 1246s 757us/sample - loss: 6.9693
In [49]:
most_similar('Garten',vectors_sg,10)
Out[49]:
[('Grundstück', 0.49052674),
 ('Saal', 0.4734893),
 ('Schloss', 0.44753963),
 ('Villa', 0.43492228),
 ('Geheimnis', 0.4325289),
 ('Haus', 0.43189895),
 ('Park', 0.4315134),
 ('Burganlage', 0.41834962),
 ('Bahnhofs', 0.41127938),
 ('Kapelle', 0.40979788)]
In [50]:
most_similar('betrachten',vectors_sg,10)
Out[50]:
[('gebunden', 0.41257176),
 ('getan', 0.40452704),
 ('vermuten', 0.40196273),
 ('verwenden', 0.3988522),
 ('annehmen', 0.38509768),
 ('modernen', 0.38280833),
 ('betrachtet', 0.37809587),
 ('versteht', 0.37669933),
 ('schaffen', 0.37390935),
 ('behandeln', 0.37386188)]
In [55]:
most_similar('Schweden',vectors_sg,10)
Out[55]:
[('Finnland', 0.61242205),
 ('Dänemark', 0.5798253),
 ('Achtelfinale', 0.5448033),
 ('Norwegen', 0.54214346),
 ('Italien', 0.53428847),
 ('Oslo', 0.5255631),
 ('Titelverteidiger', 0.51630586),
 ('Polen', 0.50975597),
 ('Frankreich', 0.50956994),
 ('England', 0.50827694)]
In [56]:
most_similar('Direktor',vectors_sg,10)
Out[56]:
[('Leiter', 0.7581985),
 ('Vorsitzender', 0.6964056),
 ('Geschäftsführer', 0.6639749),
 ('Leitung', 0.6128399),
 ('Vorstandsmitglied', 0.6108497),
 ('Generalsekretär', 0.60028607),
 ('Mitbegründer', 0.59778315),
 ('Chefredakteur', 0.59448296),
 ('Dekan', 0.5909957),
 ('Vorstand', 0.5849248)]
In [57]:
most_similar('Park',vectors_sg,10)
Out[57]:
[('Avenue', 0.6083627),
 ('Town', 0.5915627),
 ('Valley', 0.5910832),
 ('Station', 0.5823999),
 ('District', 0.5543256),
 ('Road', 0.5462431),
 ('Mount', 0.5381234),
 ('Centre', 0.5292357),
 ('Railway', 0.52788293),
 ('Parks', 0.5225033)]
In [58]:
evaluate(testdata,vectors_sg)
Out[58]:
0.5219781039123557
In [104]:
evaluate_bin(testdata_stw,vectors_sg)
Out[104]:
0.9377869605142333

Die Ergebnisse der Evaluierung sind jetzt nochmal wesentlich besser.

Gensim Word2vec Bibliothek

Die Ergebnisse unserer einfachen Implementierung sind zwar gut, aber das Training kostet sehr viel Zeit und wir haben viele häufige Wörter nicht berücksichtigt. Würden wir alle Wörter, die mindestens 10 mal vorkommen berücksichtigen, hätten wir etwas 10 mal so viele Trainingsdaten und würden nochmal wesentlich mehr Zeit zum trainieren brauchen. Es gibt aber einen Trick, um effizient mit häufigen Wörtern umzugehen: wir berücksichtigen nicht jedes Wortpaar, sondern wählen zufällig Wortpaare aus, wobei die Wahrscheinlichkeit dass ein Kontextwort gewählt wird, kleiner wird, wenn seine Häufigkeit im Korpus größer ist. In der Architektur vom Netz und in der Loss-Funktion können noch einige Optimierungen vorgenommen werden. Es gibt verschiedene Bibliotheken, die das bereits alles effizient implementiert haben. Wir schauen uns zum Schluss die Gensim Bibliothek an, und nehmen die gleiche Parameter, die wir auch für die frühere Modelle genutzt haben:

In [71]:
import gensim 

model = gensim.models.Word2Vec(sentences, min_count=10, window=2,size = 100)
In [80]:
print(model.wv.similarity('Kirche','Kloster'))
print(model.wv.similarity('Kirche','Schweden'))
print(model.wv.similarity('Deutschland','Schweden'))
0.61492544
0.21266891
0.6948331
In [81]:
model.wv.most_similar(positive=['Garten'], topn=10)
Out[81]:
[('Tempel', 0.8393540382385254),
 ('Testament', 0.8374817371368408),
 ('Saal', 0.8259246945381165),
 ('Kern', 0.8225860595703125),
 ('Hafen', 0.806635856628418),
 ('Palast', 0.8049925565719604),
 ('Altar', 0.8034838438034058),
 ('Ring', 0.8016954660415649),
 ('Grab', 0.7969426512718201),
 ('Leuchtturm', 0.7871773838996887)]
In [82]:
model.wv.most_similar(positive=['Park'], topn=10)
Out[82]:
[('Bundesstaat', 0.7742149829864502),
 ('District', 0.7470247745513916),
 ('Saal', 0.7351523637771606),
 ('County', 0.7285239696502686),
 ('Canyon', 0.7155393362045288),
 ('Stadtteil', 0.7112339735031128),
 ('Wall', 0.6992167830467224),
 ('Pavillon', 0.6980631351470947),
 ('Valley', 0.6959826946258545),
 ('Ortskern', 0.6937980651855469)]
In [83]:
model.wv.most_similar(positive=['betrachten'], topn=10)
Out[83]:
[('akzeptieren', 0.9265627861022949),
 ('gestalten', 0.9240304231643677),
 ('interpretieren', 0.9215875864028931),
 ('pflegen', 0.9165897965431213),
 ('untersuchen', 0.9161754846572876),
 ('zerstören', 0.9157392978668213),
 ('entfernen', 0.9131984710693359),
 ('verstehen', 0.9130492210388184),
 ('finanzieren', 0.9122571349143982),
 ('ändern', 0.9112906455993652)]
In [84]:
model.wv.most_similar(positive=['Schweden'], topn=10)
Out[84]:
[('Frankreich', 0.8886335492134094),
 ('Ungarn', 0.8779322504997253),
 ('Großbritannien', 0.874965488910675),
 ('Italien', 0.8739466071128845),
 ('Finnland', 0.8619193434715271),
 ('Spanien', 0.8478871583938599),
 ('Australien', 0.8477784395217896),
 ('Polen', 0.844699501991272),
 ('Japan', 0.8430098295211792),
 ('Dänemark', 0.8381272554397583)]
In [85]:
model.wv.most_similar(positive=['Direktor'], topn=10)
Out[85]:
[('Leiter', 0.9265033006668091),
 ('Vizepräsident', 0.9075117111206055),
 ('Geschäftsführer', 0.8970165848731995),
 ('Präsident', 0.8923015594482422),
 ('Sekretär', 0.8895034790039062),
 ('Vorsitzender', 0.8722403049468994),
 ('Chefredakteur', 0.8562122583389282),
 ('Vorstandsmitglied', 0.831561803817749),
 ('Chef', 0.830780029296875),
 ('Mitbegründer', 0.8215608596801758)]

Da das Model selber eine Methode most similar hat und wir nicht direkt mit den Vektoren arbeiten, müssen wir unsere Evaluierungsfunktion etwas anpassen:

In [116]:
def evaluate_w2v(data,model):
    gold = []
    predicted = []
    for v,w,sim in data:
        pred = model.wv.similarity(v,w)

        gold.append(sim)
        predicted.append(pred)
        #print(v,w,pred,sim,sep = '\t')
        
    
    av_p = sum(predicted)/len(predicted)
    av_g = sum(gold)/len(gold)
    
    cov = 0
    var_g = 0
    var_p = 0
    for s,t in zip(gold,predicted):
        cov += (s-av_g) * (t-av_p)
        var_g += (s-av_g) * (s-av_g)
        var_p += (t-av_p) * (t-av_p)
        
    return cov / math.sqrt(var_g*var_p)


def evaluate_bin_w2v(data,model):
    labels = []
    predictions = []
    for v,w,sim in data:
        pred = model.wv.similarity(v,w)
        labels.append(sim)
        predictions.append(pred)
    fpr, tpr, thresholds = metrics.roc_curve(labels, predictions, pos_label='+')
    return metrics.auc(fpr, tpr) 
In [117]:
evaluate_w2v(testdata,model)
Out[117]:
0.3652095278322509
In [119]:
evaluate_bin_w2v(testdata_stw,model)
Out[119]:
0.8516988062442608

Das Ergebnis ist hier etwas schlechter als bei der einfachen Implementerung mit Keras. Wir sollten hieraus aber nicht schließen, dass diese Implementierung besser ist als die Gensim-Bibliothek. Vielmehr zeigt es, dass die Evaluierung mit zwei kleinen Datensätze nicht sehr aussagekräftig ist.

Welches Verfahren die Beste Ergebnisse liefert, ist ohnehin nicht ganz einfach zu ermitteln. Neben dem Verfahren spielen die gewählte Einstellungen und die Trainingsdaten eine große Rolle. Wir haben hier für alle Verfahren etas die gleiche EInstellungen gewählt. Dabei ist aber nicht gesagt, dass diese Einstellungen für jedes Verfahren, für unsere Traningsdaten und Testaufgaben optimal sind. Eine interessante Diskussion über den Einfluss der Parameter und Trainigsdaten geben Levy, Goldberg und Dagan (2015).

Literatur

Rubenstein, H., & Goodenough, J. B. (1965). Contextual Correlates of Synonymy. Commun. ACM, 8(10), 627–633.

Ruge, G., & Schwarz, C. (1990). Linguistically based term associations—A new semantic component for a hyperterm system. Proceedings of 1st International ISKO-Confercence, Darmstadt, 88-96.

Crouch, C. J. (1990). An approach to the automatic construction of global thesauri. Information Processing & Management, 5, 629–640.

Crouch, C. J., & Yang, B. (1992). Experiments in automatic statistical thesaurus construction. In Proceedings of the 15th annual international ACM SIGIR conference on Research and development in information retrieval (pp. 77-88). ACM.

Grefenstette, G. (1992). Use of syntactic context to produce term association lists for text retrieval. 89–97.

Karlgren, J., & Sahlgren, M. (2001). From Words to Understanding. In Foundations of Real-World Intelligence (S. 294–308). CSLI Publications.

Bullinaria, J. A., & Levy, J. P. (2007). Extracting semantic representations from word co-occurrence statistics: A computational study. Behavior research methods, 39(3), 510-526.

Mohammad, S. M., & Hirst, G. (2012). Distributional measures of semantic distance: A survey. arXiv preprint arXiv:1203.1858.

Bullinaria, J. A., & Levy, J. P. (2012). Extracting Semantic Representations from Word Co-occurrence Statistics: Stop-lists, Stemming and SVD. Behaviour Research Methods, 44(3), 890–907.

Mikolov, T., Sutskever, I., Chen, K., Corrado, G. S., & Dean, J. (2013). Distributed representations of words and phrases and their compositionality. In Advances in neural information processing systems (pp. 3111-3119).

Kiela, D., & Clark, S. (2014). A Systematic Study of Semantic Vector Space Model Parameters. In 2nd Workshop on Continuous Vector Space Models and their Compositionality (CVSC).

Levy, O., Goldberg, Y., & Dagan, I. (2015). Improving distributional similarity with lessons learned from word embeddings. Transactions of the Association for Computational Linguistics, 3, 211-225.