Für die Analyse von Texten kann es aufschlussreich sein, die konkrete Verwendung einzelner Begriffe in den Blick zu nehmen. Dafür bietet sich ein Verfahren an, bei dem der gesuchte Schlüsselbegriff zusammen mit den Wörtern dargestellt wird, die ihn umgeben. Der Begriff erscheint also in seinem Bedeutungszusammenhang. Das Verfahren heißt deswegen “keyword in context” oder einfach “KWIC”.1
Der unangefochtene Platzhirsch im Bereich des Natural Language Processing (NLP), also der computergestützten Verarbeitung natürlicher Sprache, ist Python. Zahlreiche Bibliotheken wie NLTK oder Gensim stehen hier zur Verfügung und sind ungemein mächtig. Genau darin kann aber ein Problem bestehen: Mitunter schießt man mit Kanonen auf Spatzen, wenn man diese Bibliotheken benutzt, aber eigentlich nur ein kleines Programm benötigt, um ein spezielles Problem zu lösen.
In diesem Blogbeitrag zeige ich, wie sich mit Ruby ein einfaches Kwic-Programm schreiben lässt.
Wie lässt sich ein Programm schreiben, das möglichst einfach einen Suchbegriff in seinem Kontext anzeigt? Ein komfortabler Weg besteht darin, die einzelnen Zeilen, die möglicherweise den zu suchenden Begriff enthalten, in ihre Einzelwörter zu zerlegen und dann Wort für Wort durchzugehen. Findet das Programm den Begriff, gibt es ihn gemeinsam mit einer Anzahl von ihm umgebenden Wörtern aus.
Außerdem sollte das Programm zwei Anforderungen erfüllen: Zum einen wäre es praktisch, wenn es sich von der Kommandozeile aufrufen ließe und in guter Unix-Manier über eine Weiterleitung, eine sogenannte Pipe, mit anderen Programmen verkettet werden könnte. Zum anderen wäre es wünschenswert, dass auch größere Dateien mit ihm analysierbar blieben, ohne den Arbeitsspeicher allzu sehr zu belasten. Das Programm sollte also nicht allzu große Datenmengen zwischenspeichern, sondern seinen Input als Datenstrom behandeln.
Damit das alles möglichst vielseitig eingesetzt werden und mit anderen Ruby-Programmen kombiniert werden kann, packen wir unseren Code am besten in eine Klasse, das heißt in eine Bauanleitung, wie ein Kwic-Objekt beschaffen sein soll.
class Kwic
def initialize(keyword)
@keyword ||= keyword
end
end
concordance = Kwic.new("Suchbegriff")
Unsere Klasse besteht dabei zunächst nur aus der Methode initialize(keyword)
, die automatisch ausgeführt wird, wenn wir ein Kwic-Objekt erschaffen. In ihr weisen wir der Variabel @keyword
den gesuchten Begriff zu.
Um unser Programm zu starten, erstellen wir eine neue Instanz unseres Kwic-Objektes mit dem Namen ‘concordance’ und geben ihm als Argument den gesuchten Begriff mit.
Nun kümmern wir uns darum, den Input unseres Programm einzulesen und in einzelne Wörter aufzuteilen!
ARGV
und ARGF
Hierbei helfen zwei Objekte weiter, die uns Ruby standardmäßig zur Verfügung stellt: ARGV
und ARGF
. Beide sind miteinander verwandt.
ARGV
benimmt sich wie ein Feld, oder Array, das alle Argumente enthält, die beim Aufruf des Programms angegeben wurden. Starten wir es also von der Kommandozeile mit dem Befehl ruby kwic.rb foo bar
, dann enthält ARGV
die Werte foo
und bar
. ARGV
benimmt sich also so, als ob wir in unserem Programm die Zeile zu stehen hätten ARGV = ['foo', 'bar']
.
Demgegenüber ist ARGF
ein virtuelles File
-Objekt. Das heißt, es tut so, als ob sich in ihm alle Dateien der Reihe nach befinden, die bei Programmstart als Argumente genannt wurden. Starten wir also unser Programm mit ruby kwic.rb file1.txt file2.txt
, dann erstellt ARGF
einen Datenstream, der nacheinander file1.txt
und file2.txt
einliest und an das Programm weiterleitet.
Da ARGV
und ARGF
quasi Geschwister sind und sich gut miteinander verstehen, befinden sich die Dateien, die in ARGF
enthalten sind nur so lange in ARGV
, bis auf ARGF
zugegriffen wird.
Wir erstellen als nächstes eine Methode für unsere Object Kwic, mit der wir den Datenstream von ARGF
einlesen:
def read_stream
ARGF.each do |line|
process(line)
end
end
Innerhalb unserer Methode erstellen wir einen Block: Von jedem Element innerhalb von ARGF
soll einzeln jede Zeile ausgelesen werden. Diese Zeile übergeben wir einer noch zu schreibenden Methode process()
als Argument.
Damit wir die eingelesenen Zeilen möglichst einfach nach unserem Keyword durchsuchen können, zerlegen wir sie in ihre Bestandteile. Das bedeutet, wir trennen die Wörter voneinander und packen sie einzeln in ein Feld (Array) namens ‘@wordlist’.
Zunächst ergänzen wir daher den Bauplan für unser Kwic-Objekt:
class Kwic
def initialize(keyword)
@keyword ||= keyword
@wordlist = []
end
end
Anschließend erstellen wir eine Methode, die unsere eingelesenen Zeilen verarbeitet.
def process(line)
@wordlist = line.split
search_keyword
end
Wir legen als Parameter der Methode fest, dass sie eine Zeile als Argument erhält. Diese Zeile spalten wir dort auf, wo sich Leerzeichen befinden (line.split
) und packen die so gewonnenen Elemente in das Feld @wordlist
. Danach rufen wir eine neue Methode auf, die in dem erstellten Feld nach dem Keyword sucht.
def search_keyword
@wordlist.each_index do |i|
if @wordlist[i] =~ /#{@keyword}/i
print_keyword_in_context(i)
end
end
end
Von jedem Element in @wordlist
erfragen wir seinen Index und übergeben ihn in der Variable i
an einen Block. In dem Block testen wir, ob das Element an der Stelle i
im Feld @wordlist
mit unserem Suchbegriff übereinstimmt. Wir verwandeln dabei den Suchbegriff mithilfe von /#{@keyword}/i
in einen regulären Ausdruck, der nicht zwischen Groß- und Kleinschreibung unterscheidet. Falls das Element mit dem Suchbegriff übereinstimmt, rufen wir die nächste Methode namens print_keyword_in_context(i)
mit dem Index als Argument auf.
Die Methode print_keyword_in_context()
wiederum ruft zwei weitere Methoden auf und gibt dazwischen das gesuchte Keyword aus.
def print_keyword_in_context(i)
print_before_keyword(i)
print " #{@wordlist[i]} "
print_after_keyword(i)
end
Wir greifen dafür nicht auf puts
zurück, sondern auf print
, weil print
keinen Zeilenwechsel am Ende durchführt.
In den beiden Methoden davor und danach prüfen wir, an welcher Stelle sich unser Element im erstellten Zeilen-Array befindet. Wenn es unter den ersten vier Elementen ist, geben wir alle vorherigen bis zum gesuchten Element aus. Falls es an vierter oder späterer Stelle kommt, gehen wir vier Positionen im Index zurück und geben von dort aus vier Wörter aus.
def print_before_keyword(i)
if i < 4
printf '%*s', width_text_snippet, @wordlist[0, i].join(' ')
else
printf '%*s', width_text_snippet, @wordlist[i - 4, 4].join(' ')
end
end
printf
ist eine Methode, die es uns ermöglicht, Strings zu interpolieren, also Werte dynamisch einzufügen. Das %
gibt dabei die Position im String an. *
bedeutet, dass die Länge der Zeichenkette von der ersten angegebenen Variablen abhängt.s
gibt schließlich an, dass es sich wiederum um einen String handelt, der eingefügt wird. Die Länge des Strings passen wir mit der Methode width_text_snippet
an. Sie besteht nur aus einer Zeile: (75 - @keyword.length) / 2
. Mit ihr Berechnen wir den Abstand, den wir vor und hinter dem Keyword benötigen. Dazu subtrahieren wir von der Gesamtbreite der Ausgabe (75) die Zeichenlänge des Keywords (@keyword.length
) und teilen das Ergebnis durch Zwei, weil es für den Bereich vor und nach dem Suchbegriff steht. Mithilfe der Methode join(' ')
fügen wir die einzelnen Elemente aus dem Feld @wordlist
wieder zusammen und verbinden sie mit einem Leerzeichen.
Die Methode print_after_keyword()
funktioniert analog zu print_before_keyword()
. Hier testen wir nur, ob unser gefundenes Element vier Positionen vom Ende des Feldes @wordlist
entfernt ist. Falls dem so ist, geben wir die Zeile nur bis zu ihrem Ende aus. Ansonsten würde die Ausgabe wieder am Anfang der Zeile starten. Die Methode sieht wie folgt aus:
def print_after_keyword(i)
if i + 4 >= @wordlist.length
last = @wordlist.length - 1
printf '%-*s\n', width_text_snippet, @wordlist[i + 1, last].join(' ')
else
printf '%-*s\n', width_text_snippet, @wordlist[i + 1, 4].join(' ')
end
end
Damit ist das Konkordanz-Programm fertig:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Kwic
def initialize(keyword)
@keyword ||= keyword
@wordlist = []
end
def read_stream
@processing_file = ARGF.filename
ARGF.each do |line|
process(line)
end
end
def process(line)
@wordlist = line.split
search_keyword
end
def search_keyword
@wordlist.each_index do |i|
if @wordlist[i] =~ /#{@keyword}/i
print_keyword_in_context(i)
end
end
end
def print_keyword_in_context(i)
print_before_keyword(i)
print " #{@wordlist[i]} "
print_after_keyword(i)
end
def print_before_keyword(i)
if i < 4
printf '%*s', width_text_snippet, @wordlist[0, i].join(' ')
else
printf '%*s', width_text_snippet, @wordlist[i - 4, 4].join(' ')
end
end
def print_after_keyword(i)
if i + 4 >= @wordlist.length
last = @wordlist.length - i
printf "%-*s\n", width_text_snippet, @wordlist[i + 1, last].join(' ')
else
printf "%-*s\n", width_text_snippet, @wordlist[i + 1, 4].join(' ')
end
end
def width_text_snippet
(75 - @keyword.length) / 2
end
end
concordance = Kwic::Kwic.new(ARGV.shift)
concordance.read_stream
Eine etwas erweiterte und ausführlichere Version von kwic.rb
ist auf meiner Github-Seite hinterlegt.
Zu dieser Form der Konkordanz vgl. auch den Wikipedia-Eintrag. ↩