KeyWords In Context mit Ruby

Veröffentlicht von Ramon Voges am 20.12.2017 10 Minuten zum Lesen

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.

Vorüberlegungen

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.

Den Text verarbeiten

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.

Treffer ausgeben

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

Das ganze Skript

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.

  1. Zu dieser Form der Konkordanz vgl. auch den Wikipedia-Eintrag