Reguläre Ausdrücke

Was sind reguläre Ausdrücke?

Reguläre Ausdrücke stammen aus dem Gebiet der Automatentheorie, die wiederum Teil der theoretischen Informatik ist. Zu jedem regulären Ausdruck (regulär deshalb, weil zur Familie der regulären Sprachen gehörend) besteht ein endlicher Automat, der den Ausdruck akzeptiert. Ein endlicher Automat ist einfach eine Zustandsmaschine mit einer endlichen Menge von Zuständen, die diese einnehmen kann.

In der Praxis werden reguläre Ausdrücke vor allem zum Mustervergleich auf Zeichenketten angewendet.

Diese Ausrücke sind weitgehend sprachübergreifend: bis auf einige kleine Abweichungen lässt sich eine regular expression in Programmiersprachen wie Python, Perl oder Java gleich schreiben. Auch viele Texteditoren bieten ebenso Unterstützung für reguläre Ausdrücke wie z.B. auch MS Word.

Wozu reguläre Ausdrücke?

Wir haben bisher einige Möglichkeiten kennen gelernt, in einem String nach einem Substring zu suchen.

Den in-Operator:


In [ ]:
firstnames = ['Astrid', 'Ines', 'Christoph', 'Markus', 'Çınar', 'Đželila', 'Niklas', 'Anna', 'Stefanie', 'Raphael', 'Anna-Lena', 'Silvia', 'Julian', 'Simon', 'Katharina', 'Michael', 'Dominik', 'Maria', 'Kevin', 'Bianca', 'Thomas', 'Nora', 'Manuel', 'Selina', 'Gabriel', 'Daniel', 'Thomas', 'Nina', 'Michael', 'Fabio', 'Theresa', 'Manuel', 'Carina', 'Philipp', 'Lukas', 'Wolfgang', 'Anna', 'Doris', 'Thomas', 'Muhammed', 'Christoph', 'Lisa-Marie', 'Jessica', 'Maria', 'Thomas', 'Florian', 'Martin', 'Anna', 'Oliver', 'Gregor', 'Helmut', 'Florian', 'Matteo', 'David', 'Marlene', 'Vanessa', 'Lea', 'Jan', 'Béla', 'Verena', 'Manuel', 'Björn', 'Tobias', 'Denise', 'Emma', 'Lukas', 'Sarah', 'Oliver', 'Janine', 'Manuel', 'Georg', 'Lorenz', 'Verena', 'Caroline', 'Laura', 'Felix', 'Simon', 'Lea', 'Peter', 'Sandra', 'Julia', 'Sophie', 'Jacqueline', 'Nina', 'Sebastian', 'David', 'Matthias', 'Patrick', 'Selina', 'Fabian', 'Daniel', 'Sabine', 'Josef', 'Lisa', 'Carina', 'Florian', 'Fabian', 'Viktoria', 'Christoph', 'Emilia']
for firstname in firstnames:
    if 'ie' in firstname:
        print(firstname)

startswith() und endswith():


In [ ]:
[firstname for firstname in firstnames if firstname.startswith('A')]

In [ ]:
[firstname for firstname in firstnames if firstname.endswith('o')]

Reguläre Ausdrücke (oder regular expressions) erweitern die Möglichkeiten, in einem String nach einem Muster zu suchen, enorm. Sie sind auch nicht Python-spezifisch, sondern wie bereits gesagt, in den meisten Programmiersprachen verfügbar. Aber auch viele Texteditoren und Textverarbeitungsprogramme unterstützen reguläre Ausdrücke.

Ein regulärer Ausdruck ist ein in einer speziellen Syntax geschriebenes Muster, das auf einen String angewendet wird.

Hier gleich ein Tipp: Reguläre Ausdrücke sind nicht gerade berühmt für ihre einfache Nachvollziehbarkeit. Erfahrungsgemäß ist hier die Verwendung von Regex-Testern sehr hilfreich. Hier zwei Empfehlungen:

Reguläre Ausrücke in Python

In Python werden reguläre Ausdrücke über das Modul re bereitgestellt, das wir zuerst laden müssen:


In [ ]:
import re

Das re-Modul stellt eine Reihe von Funktionen bereit, darunter die Funktion search(), mit der nach einem Muster in einem String gesucht werden kann. search() erwartet zwei Argumente: das zu suchende Muster und den String, auf den das Muster anzuwenden ist.

Wird das Muster gefunden, liefert search() ein Match-Objekt:


In [ ]:
re.search('de', 'abcdef')

Wird das Muster nicht gefunden, liefert search() None zurück:


In [ ]:
print(re.search('xyz', 'abcdef'))

Dasselbe hätte wir allerdings auch mit einem simplen 'xyz' in 'abcdef' erreichen können:


In [ ]:
'xyz' in 'abcdef'

Die Mächtigkeit von regulären Ausdrücken ergibt sich erst aus der Möglichkeit, komplexere Muster zu definieren.

Muster

Auf Anfang und Ende des Strings testen

Reguläre Ausdrücke verwenden das Zeichen ^ um den Anfang des Strings zu markieren. Das gesuchte Muster muss also am Anfang des Strings stehen:


In [ ]:
re.search('^ab', 'abc')

In [ ]:
re.search('^ab', 'cab')

Reguläre Ausdrücke verwenden das Zeichen $ um das Ende des Strings zu markieren:


In [ ]:
re.search('z$', 'xyz')

In [ ]:
re.search('z$', 'xyz!')

Zum Nachdenken: Auf welchen String passt dieses Muster?


In [ ]:
re.search('^abc$', '')

Beliebige Zeichen und Quantoren

Der Punkt steht in einem regulären Ausdruck für jedes beliebige Zeichen.


In [ ]:
re.search('v.n', 'Guido van Rossum')

In [ ]:
re.search('v.n', 'Anton von Webern')

Jedes Zeichen lässt sich mit einem Quantor kombinieren, der angibt, wie oft das Zeichen an dieser Stelle vorkommen muss (oder darf). Folgende Quantoren sollten Sie kennen:

  • *: Das Stern-Zeichen bedeutet 0 bis beliebig viele Wiederholungen.
  • +: Das Plus steht für eine oder mehr Wiederholungen
  • ?: Das Fragezeichen steht für keine oder eine Wiederholung

Beliebig viele Wiederholungen (*)

Ein Quantor kann mit jedem Zeichen kombiniert werden- Hier kombinieren wir den Quantor * mit dem Zeichen a, was soviel bedeutet wie: An dieser Stelle kann kein, ein oder beliebig oft das Zeichen a erscheinen.


In [ ]:
for s in ['Pr', 'Par', 'Paar', 'Paaar']:
    if re.search('a*', s):
        print(s)

Kombiniert man den Quantor * mit dem Platzhalterzeichen ., so bedeutet das, dass an dieser Stelle jedes Zeichen beliebig oft vorkommen kann, es wird also eine beliebige Menge beliebiger Zeichen damit abgedeckt.


In [ ]:
re.search('G.*v', 'Guido van Rossum')

Das Muster passt auf Guido v, weil zwischen dem G und dem v beliebig viele Zeichen erlaubt sind. Beliebig viele inkludiert aber auch keine, wie wir an diesem Beispiel sehen können:


In [ ]:
re.search('R.*o', 'Guido van Rossum')

obwohl das o unmittelbar auf das R folgt, also kein weiteres Zeichen dazwischen steht, passt unser Muster.

Eine oder mehr Wiederholungen (+)

Verwenden wir statt * den Quantor + (1 oder mehr Wiederholungen) passt das Muster für Ro nicht mehr:


In [ ]:
re.search('R.+o', 'Guido van Rossum')

Dieses Muster wird hingegen nach wie vor gefunden:


In [ ]:
re.search('G.+v', 'Guido van Rossum')

Keine oder eine Wiederholung (?)

Das folgende Beispiel (R.?s) passt, weil das Fragezeichen für keine oder eine Wiederholung steht, auf z.B. Ros oder Rus, aber auch auf Rs, aber nicht auf Roos.


In [ ]:
for s in ['Ros', 'Rus', 'Rxs', 'Rs', 'Roos']:
    if re.search('R.?s', s):
        print(s)

Eine bestimmte Zahl von Wiederholungen

Wenn wir ein Muster festlegen wollen, in dem genau zwei Wiederholungen gesucht sind, können wir das so angeben: {2}


In [ ]:
for s in ['Rs', 'Ras', 'Raas', 'Raus', 'Raaas']:
    if re.search('R.{2}s', s):
        print(s)

Ein Intervall von Wiederholungen

Es ist auch möglich, im Muster festzulegen, dass z.B. 1, 2 oder 3 Wiederholungen erlaubt sind. Dazu werden zwei Zahlen (min, max) zwischen die geschwungenen Klammern geschrieben:


In [ ]:
for s in ['Rs', 'Ras', 'Rees', 'Riiis', 'Roooos']:
    if re.search('R.{1,3}s',s ):
        print(s)

Quantoren funktionieren nicht nur mit Platzhaltern

Noch einmal zur Erinnerung: Quantoren können nicht zusammen mit dem Platzhalterzeichen . verwendet werden, sondern mit allen Zeichen oder Zeichenklassen:


In [ ]:
for s in ['Pr', 'Par', 'Pur', 'Paar', 'Paur', 'Paaar']:
    if re.search('a+', s):
        print(s)

Übung: versuchen Sie das obige Beispiel auch mit anderen Quantoren!

Muster sind gierig

Eine Regex-Engine versucht immer das breiteste Muster zu finden. Das wird deutlicher, wenn wir im nächsten Beispiel statt re.search() re.findall() verwenden. Diese Funktion liefert kein Match-Objekt, sondern eine Liste aller Substrings auf die das Muster passt.


In [ ]:
re.findall('G.*o', 'Guido van Rossum')

Der gefundene Teilstring ist nicht, wie man vielleicht erwarten könnte, Guido, sondern den längsten String, auf den das Muster passt: Guido van Ro. Da dies nicht immer erwünscht ist, können wir einen Quantor durch ein nachgestelltes Fragezeichen als non-greedy markieren:


In [ ]:
re.findall('G.*?o', 'Guido van Rossum')

Um noch die Funktionsweise von findall() zu demonstrieren, ein weiteres Beispiel:


In [ ]:
re.findall('.aa.', 'Ein paar Paare ohne Haar')

Groß- und Kleinschreibung ignorieren

Normalerweise wird bei regulären Ausdrücken zwischen Groß- und Kleinschreibung unterschieden:


In [ ]:
re.findall('a.{0,2}', 'Anton arbeitet am liebsten allein.')

Wenn wir diese Unterscheidung nicht wollen, müssen wir die Regex-Funktion mit dem Flag re.I (oder: re.IGNORECASE) aufrufen:


In [ ]:
re.findall('a.{0,2}', 'Anton arbeitet am liebsten allein.', re.I)

Zeichenklassen

Bisher haben wir nur mit bestimmten Zeichen oder mit dem Platzhalter für ein beliebiges Zeichen gearbeitet. Oft wäre es aber nützlich, wenn wir nur auf bestimmte Zeichen, wie zum Beispiel alle Vokale testen könnten. Regular Expressions stellen dazu das Konzept der Zeichenklasse zur Verfügung. Alle Zeichen innerhalb eckiger Klammern werden als Mitglieder der Zeichenklasse betrachtet. Der Ausdruck [aeiou] passt somit auf jeden Vokal.


In [ ]:
re.findall('[aeiou].{0,2}', 'Anton arbeitet am liebsten allein.')

Alle Kleinbuchstaben können wir so angeben: [a-z]


In [ ]:
re.findall('[a-z]', 'Anton arbeitet am liebsten allein.')

Das funktioniert auch für Ziffern: [0-9]


In [ ]:
re.findall('[0-9]', 'Anton hat 1 Pferd und 2 Katzen.')

Übung: Versuchen Sie den folgenden Ausdruck so umzuschreiben, dass das Ergebnis ['12', '3'] ist:


In [ ]:
re.findall('[0-9]', 'Anna hat 12 Kühe Pferd und 3 Hunde.')

Wie können aber auch alle Groß- und Kleinbuchstaben als Zeichenklasse definieren: [a-zA-Z]


In [ ]:
re.findall('[a-zA-Z]', 'Anton arbeitet am liebsten allein.')

Einige Zeichenklassen sind vordefiniert und können uns das Leben erheblich erleichtern:

  • \s steht für Whitespace (Leerzeichen, Tabulatoren, Zeilenumbrüche usw.)
  • \S steht für jedes Zeichen, dass kein Whitespace ist. (Diese Umkehrung durch Großschreibung funktioniert für alle hier vorgestellten Zeichenklassen)
  • \b steht für einen leeren String am Anfang oder Ende eines Worts (word boundary)
  • \d steht für ein decimal digit, eine Ziffer in irgendeinem Zeichensystem, das in Unicode definiert ist (Siehe hier).
  • \w steht für ein "Wortzeichen". In ASCII entspricht das [a-zA-Z0-9_]

Da manche Backslash-Kombinationen (wie \b) eine besondere Bedeutung haben, müssen wir sie entweder durch einen zweiten Backslash escapen oder den Ausdruck als Raw String schreiben (gekennzeichnet durch ein vor den String gestelltes r). Dieses Beispiel funktioniert nicht:


In [ ]:
re.findall('\b[A-Z].*?\b', 'Anton hat 1 Pferd und 2 Katzen.')

Das hingegen schon:


In [ ]:
re.findall('\\b[A-Z].*?\\b', 'Anton hat 1 Pferd und 2 Katzen.')

Grundsätzlich sollten Sie sich zur Angewohnheit machen, Muster immer als Raw Strings zu schreiben, indem sie ein r vor den Ausdruck stellen:


In [ ]:
re.findall(r'\b[A-Z].*?\b', 'Anton hat 1 Pferd und 2 Katzen.')

Dieses neue Wissen können wir wir zum Beispiel anwenden, um alle Wörter zu finden, in denen zwei Vokale aufeinander folgen:


In [ ]:
s = "Ein paar Paare in der Bar machen ohne Haare viele Klare alle"
re.findall(r'\b\w*[aeiou]{2}\w*', s)

Noch einmal zur Erinnerung: \b steht für word boundary, \w für ein Wortzeichen ([a-zA-Z0-9_]).

Alternativen

Zeichenklasse stehen immer für ein einzelnes Zeichen. Falls wir nach einer von mehreren Zeichenkombinationen suchen wollen, brauchen wir statt Zeichenklassen Alternativen:


In [ ]:
s = 'Anton hat 12 Kühe, 1 Pferd, 2 Hunde und 3 Katzen'
re.findall(r'Katzen?|Pferde?|Hunde?', s)
Zur Erinnerung: Das Fragezeichen nach einem Zeichen steht für "keinmal oder einmal". `Katzen?` passt also auf `Katze` und `Katzen` (allerings auch auf `Katzel`).

Wir können uns sogar die Zahl der Tiere mitausgeben lassen, wenn wir davon ausgehen, dass die Zahl vor dem Tier steht und auf die Zahl ein Whitespace folgt:


In [ ]:
s = 'Anton hat 12 Kühe, 1 Pferd, 2 Hunde und 3 Katzen'
re.findall(r'\d+\s+Pferde?|\d+\s*Hunde?|\d+\s*Katzen?', s)

Greifen wir das Beispiel von oben mit den Vokalen noch einmal auf. Falls wir nur an Doppelvokalen (und nicht an zwei aufeinander folgenden Vokale) interessiert sind, können wir mit Alternativen arbeiten:


In [ ]:
s = "Ein paar Paare in der Bar machen ohne Haare viele Klare alle"
re.findall(r'\b\w*aa\w*|\b\wee\w*|\b\wii\w*|\b\woo\w*|\b\wuu\w*', s)

Dieses Muster lässt sich natürlich auch auf längere Texte anwenden. Finden wir heraus, welche Wörter mit Doppelvokalen Jakob Wassermann verwendet hat:


In [ ]:
with open('../data/wassermann/der_mann_von_vierzig_jahren.txt') as fh:
    text = fh.read()
re.findall(r'\b\w*aa\w*|\b\wee\w*|\b\wii\w*|\b\woo\w*|\b\wuu\w*', text)

Übung: Hier wäre es natürlich übersichtlicher, wenn jedes Wort nur ein Mal vorkommen würde und die Ausgabe alphabetisch sortiert wäre!


In [ ]:

Match-Objekte

Zur Auffrischung: re.search(), das wir bereits kennengelernt haben, liefert, wenn das Muster gefunden wurde, ein Match-Objekt:


In [ ]:
re.search('v.n', text)

Weisen wir das Match-Objekt einer Variable zu, um es genauer zu untersuchen:


In [ ]:
m = re.search('v.n', text)
dir(m)

Ein Match-Objekt hat Funktionen, mit denen wir die Position des Match im String feststellen können:


In [ ]:
print(m.start(), m.end())

Die Methode span() liefert uns beide Werte als Tupel:


In [ ]:
m.span()

Besonders interessant sind die beiden Methoden groups() und group(). Damit wir diese nutzen können, müssen wir zuerst ein weiteres wichtiges Konzept von regulären Ausdrücken kennen lernen: Gruppierungen.

Wir haben oben an einem Beispiel gesehen, wie wir bestimmte Tiere mit ihrer Zahl extrahieren konnten:


In [ ]:
s = 'Anton hat 12 Kühe, 1 Pferd, 2 Hunde und 3 Katzen'
re.findall(r'\d+\s+Pferde?|\d+\s*Hunde?|\d+\s*Katzen?', s)

search() liefert nur den ersten Treffer als Match-Objekt:


In [ ]:
re.search(r'\d+\s+Pferde?|\d+\s*Hunde?|\d+\s*Katzen?', s)

Wollen wir diese Daten weiter verarbeiten, wäre es nicht schlecht, die Zahl von der Tierart zu trennen. Dazu müssen wir unseren Ausdruck durch runde Klammern gruppieren.


In [ ]:
re.search(r'(\d+)\s+(Pferde?|Hunde?|Katzen?)', s)

Auf den ersten Blick ist kein Unterschied zu sehen. Allerdings gibt es einen wesentlichen Unterschied im Match-Objekt:


In [ ]:
m = re.search(r'(\d+)\s+(Pferde?|Hunde?|Katzen?)', s)
m.groups()

Kleiner Exkurs: Tiere zählen

Mit diesem Wissen können wir zählen, wie viele Pferde, Hunde und Katzen in einem Text vorkommen. Da re.search() immer nur den ersten Treffer liefert, müssen wir uns in einer Schleife durch den Text bewegen und so lange rechts vom letzten Treffer weitersuchen, bis kein Treffer mehr vorhanden ist. Dazu können wir die Eigenschaft string des Match-Objekts verwenden, die den durchsuchten String beinhaltet, und die Methode end(), die die Position des letzten Matches liefert.


In [ ]:
s = 'Anton hat 12 Kühe, 1 Pferd, 2 Hunde und 3 Katzen.'
pattern = r'(\d+)\s+(Pferde?|Hunde?|Katzen?|Kuh|Kühe)'
animal_counter = 0
m = re.search(pattern, s)
while m:
    print(m.groups()) 
    animal_counter += int(m.group(1))
    m = re.search(pattern, m.string[m.end():])
print("{} Tiere gefunden".format(animal_counter))

Weiter Funktionen des re-Moduls

Bisher haben wir nur zwei Funktion des re-Moduls kennen gelernt: search() und findall(). Es gibt aber noch einige weitere sehr nützliche Funktionen.

match()

match() verhält sich wie search(), allerdings wird das Muster immer am Anfang des Strings gesucht. Ein match('abc', s) entspricht also einem search('^abc', s)


In [ ]:
re.match('ab', 'abcdef')

In [ ]:
re.match('ab', '-abcdef')

split()

Wir haben bereits die split()-Methode des String-Objekt kennengelernt:


In [ ]:
'1,2,3,4'.split(',')

re stellt eine ähnliche split()-Funktion zur Verfügung, bei der wir das Trennzeichen (bzw. den Trennstring) als regulären Ausdruck angeben können. Dieses Split ist dadurch viel mächtiger.

Wollen wir etwa einen Text in Sätze zerlegen, müssen wir den Text an jedem Satzzeichen trennen: .!?. Mit re.split() ist das ganz einfach:


In [ ]:
with open('../data/wassermann/der_mann_von_vierzig_jahren.txt') as fh:
    text = fh.read()
sentences = re.split(r'[.?!]\s*', text)
print(len(sentences))

In [ ]:
print(sentences[:4])

sub()

Diese Funktion entspricht der replace()-Mthode des String-Objekts. Allerdings kann der zu ersetzende Substring hier ein Muster sein. Wir könnten z.B. allen Whitespace in einem String zu einem Leerzeichen normalisieren, um mehrfache Leerzeichen und Zeilenumbrüche zu entfernen.


In [ ]:
with open('../data/wassermann/der_mann_von_vierzig_jahren.txt') as fh:
    text = fh.read()
text = re.sub('\s+', ' ', text)    
sentences = re.split(r'[\.\?\!]\s*', text)
print(sentences[:4])

Jetzt können wir beispielsweise die mittlere Satzlänge berechnen:


In [ ]:
# Für alle Fälle entfernen wir noch Sätze der Länge 0
sentences = [s for s in sentences if len(s) > 0]
sum([len(s) for s in sentences]) / len(sentences)

Oder die gemittelte Zahl von Wörtern pro Satz, den kürzesten und den längsten Satz:


In [ ]:
words_per_sentence = [len(s.split()) for s in sentences] 
print('Mittlere Wortzahl pro Satz:', sum(words_per_sentence) / len(sentences))
print('Der längster Satz hat {} Wörter, der kürzeste {}'.format(max(words_per_sentence), min(words_per_sentence)))