Proseminar Algorithmen der Bioinformatik WS 2010/11 Textkompression: Burrows-Wheeler-Transformation Johann Hawe Johann Hawe, WS 2010/11 1
Gliederung 1. Einleitung 2. BWT Kompressionstransformation 2.1 Prinzip der Transformation 2.2 Eigenschaften des Outputs 2.3 Implementierung 3. BWT Dekompressionstransformation 4. Move-To-Front-Kodierung und -Dekodierung 5. Huffman-Kodierung und -Dekodierung 6. Fazit Johann Hawe, WS 2010/11 2
1. Einleitung Die Burrows-Wheeler-Transformation, kurz BWT, entspricht einer reversiblen Transformation eines zu komprimierenden Textes. Diese Permutation des Textes erlaubt es bestimmten Kompressionsverfahren, wie z.b. die kombinierte Anwendung von Huffman- und Move-to-Front- Kodierung, diesen effizienter zu komprimieren. Eine gute und performante Kompression von Daten wird in vielen Gebieten rund um Medien und Daten im Allgemeinen benötigt, nicht zuletzt auch in der Bioinformatik. Hier ist es unter anderem wichtig, große Datensätze, wie vollständige Genome oder Proteome, effizient zu speichern und schnellen Zugriff darauf zu gewährleisten. Gerade die Tatsache, dass derzeit eine wahre Datenflut von verschiedenen Forschungseinrichtungen generiert wird, macht gute Kompressionsverfahren unabdinglich. 2. Die Kompressions-Transformation 2.1 Prinzip der Transformation Betrachten wir zuerst die Hintransformation, deren Ziel es ist, eine Inputseqeuenz S der Länge N so zu permutieren, dass die entstehende Sequenz L sich zum einen in S zurück transformieren und zum anderen besser komprimieren lässt. Hierzu erstellt man zunächst eine Matrix M, welche in jeder Zeile i die Sequenz S um i Positionen zyklisch nach rechts rotiert enthält. Die Zeilen der Matrix werden anschließend lexikographisch aufsteigend sortiert. Abb.1: Input S: artanaa Matrix M: aaartan aartana anaaart Index I = 3: artanaa naaarta rtanaaa tanaaar Johann Hawe, WS 2010/11 3
In Abb.1 fällt auf, dass in jeder Spalte von M eine Permutation von S steht. Das Ergebnis der Transformation gestaltet sich nun zum einen aus dem Index I, welcher das erste Vorkommen von S innerhalb der Matrix beschreibt (Es ist durchaus möglich, dass S in mehreren Zeilen von M vorkommt). Der zweite Teil ist eine Permutation L von S, welche durch die letzte Spalte von M beschrieben wird, also L := M[0][N-1] + + M[N-1][N-1] Somit entspricht das Ergebnis dem Tupel (L, I). In obigem Beispiel: (nataaar, 3) 2.2 Eigenschaften des Outputs Warum lässt sich diese Permutation von L nun besser komprimieren als z.b. der ursprüngliche Input S? Betrachtet man alle Wörter der deutschen Sprache, so fällt auf, dass viele Suffixe oft nur von einigen wenigen Präfixen angeführt werden können, wie z.b. das Suffix ett. Hierfür kommen als Präfixe u.a. nur Br oder B in Frage. Dieses Phänomen ist dafür verantwortlich, dass sich in der letzten Spalte von M gleiche Buchstaben in einer lokalen Umgebung anhäufen, da diese ja die Präfixe der Anfänge der Zeilen darstellen und diese lexikographisch sortiert sind. Diese Eigenschaft, dass lange Folgen gleicher Buchstaben in einer Sequenz vorkommen, sorgt dafür, dass sich diese einfach durch eine Kombination aus Move-to-Front-Kodierer und einem sog. entropy-coder, wie z.b. dem Huffman-Kodierer, komprimieren lässt. 2.3 Implementierung Das größte Problem bei der Implementierung dieses Algorithmus stellt die Sortierung aller Zeilen der Matrix dar. Das Anlegen der Matrix selbst geschieht intuitiv, wobei man darauf achten muss, nicht in jeder Zeile die komplette Sequenz abzuspeichern, sondern jeweils nur einen Zeiger auf die entsprechende Startposition in der Sequenz. Ansonsten kann es, gerade bei längeren Input-Sequenzen, zu Speicherproblemen kommen. In der Praxis ist auch eine blockweise Verarbeitung des Inputs sinnvoll, z.b. zu Blöcken von ein paar hundert kbyte bis zu einem MByte. Johann Hawe, WS 2010/11 4
Für die Sortierung der Zeilen kann man verschiedene Verfahren verwenden, wie zum Beispiel Quicksort oder ein gemischtes Verfahren aus Bucketsort und Quicksort. Da das worst-case Laufzeitverhalten bei Quicksort allerdings mit O(n 2 ) nicht zufriedenstellend ist, ist es sinnvoll effizientere bzw. passendere Sortierverfahren zu implementieren, z.b. auf Basis von Suffixtrees oder Suffixarrays (weniger Platzbedarf). Mit Hilfe solcher Suffixtrees kann die Implementierung sogar ein Laufzeitverhalten von O(n) generieren. 3. Dekompressions-Transformation Das Ziel dieses Schrittes ist es, den ursprünglichen Output der Kompressionstransformation, (L, I), in die Input-Sequenz S zurückzuführen. Zuerst bemerken wird, dass wir durch L auch die erste Spalte F der ursprünglichen Matrix M gegeben haben. Denn zum einen sind alle Spalten von M Permutationen von S, also sind auch L und F jeweils Permutationen von S. Weiterhin ist M zeilenweise lexikographisch sortiert, also ist F sortiert. Somit erhalten wir F aus L durch einfaches sortieren aller Zeichen. Nun ist es uns möglich, anhand von F und L eine neue Permutation π zu beschreiben, durch welche sich die ursprüngliche Sequenz S wiederherstellen lässt. Zur besseren Erklärung der Permutation betrachten wir zunächst noch die Matrix M', welche sich von der ursprünglichen Matrix M durch rotieren aller Zeilen um eine Position nach rechts ergibt. Es gilt also: M'[i][j] = M[i][(j-i) mod N] Da M lexikographisch aufsteigend sortiert ist, ist M' lexikographisch aufsteigend ab der zweiten Position sortiert (aufgrund der zykl. Rotation nach rechts). Ebenso gilt für M', dass jede Spalte einer Permutation von S entspricht und dass jede Zeile aus M eine äquivalente Zeile in M' besitzt. Betrachten wir nun alle Zeilen welche mit einem gemeinsamen Zeichen c beginnen. Es fällt auf, dass die relative Ordnung der Zeilen in M und M' jeweils gleich ist, da ja entsprechend die Anfangsbuchstaben gleich sind und M' ab der zweite Spalte aufsteigend sortiert ist. D. h., das erste Vorkommen von z.b. 'b' in L entspricht dem ersten Vorkommen von 'b' in F. Johann Hawe, WS 2010/11 5
Nun nehmen wir uns der Permutation π an. Mit dem jetzigen Wissen ist sie einfach zu berechnen, denn sie beschreibt im Wesentlichen in welcher Zeile von M man eine Zeile aus M' finden kann, also: M'[i][j] = M[π(i)][j] für i,j є [0: n-1] Anhand dieser Permutation, den beiden Spalten F und L sowie dem Index I kann man schließlich S bestimmen. Für jeden Index i wissen wir, dass L[i] direkt vor F[i] steht (zyklische Rotation der Zeilen). Ebenso wissen wir, dass F[π(i)] = L[i] und somit L[π(i)] direkt vor L[i] steht. Da wir zusätzlich wissen, dass L[I] = S[N-1] also das letzte Zeichen von S darstellt, bekommen wir S durch folgende Vorschrift: S[n-1-k] = L[π k(i) ] wobei π 0 (i) = i und π k+1 (i) = π(π k (i)). S wird hier von hinten nach vorne generiert. Hierbei ist nochmals zu betonen, dass für die Generierung von S im Endeffekt nur L und I benötigt werden. Die Dekompressions- Transformation kann unmittelbar intuitiv implementiert werden. 4. Move-To-Front-Kodierung Die nächsten beiden Abschnitte befassen sich der Vollständigkeit halber mit der eigentlichen Kompression des Outputs (L, I) der BWT. Die Sequenz S wird hier zuerst mit Hilfe eines Move-to-Front-Kodierers kodiert, um diese anschließend durch die Huffman-Kodierung zu komprimiert. Bei der Move-to-Front-Kodierung wird jeder Character aus L durch einen Integer kodiert, indem zunächst eine Liste R aller Charactere des zugrunde liegenden Alphabets nach einer bestimmten Reihenfolge, z.b. aufsteigend lexikographisch, generiert wird. Zusätzlich wird eine Liste K der Länge N (= Länge von L) initalisiert. Am Ende der Kodierung enthält K[i] den Code des Characters von L an der Stelle i. Nun wird L analysiert, und bei jedem Vorkommen eines Characters c in L, wird der Index von c aus R in K geschrieben. Anschließend wird R reorganisiert, indem c aus R gelöscht und am Anfang von R wieder angefügt wird. Dies führt dazu, dass lokal häufig vorkommende Zeichen in L nach vorne wandern und somit durch kleine Integer Werte kodiert Johann Hawe, WS 2010/11 6
werden können. Insbesondere das vorherige Anwenden des BWT führt häufig zu einer langen Folge von Nullen in K. Die generiert Liste K kann aufgrund der asymmetrischen Verteilung der Integer Werte anschließend gut mittels der Huffman-Kodierung komprimiert werden. Die inverse Operation der Move-to-Front-Kodierung kann einfach implementiert werden. Es genügt hier die Liste R auf die gleiche Weise wie bei der Kodierung zu generieren und für jeden Integer i in K den Character an der Stelle R[i] in L zu schreiben. Anschließend muss R wieder durch Entfernen und vorne Anfügen von c in R reorganisiert werden. 5. Huffman-Kodierung Für die Huffman-Kodierung sehen wir uns zunächst einen normalen Text in deutscher Sprache an. Typischerweise kann jedes Zeichen des Textes als 8- Bit-Code bzw. ASCII-Code ausgedrückt werden. Dies wäre eine optimale Codierung, würden alle Zeichen in dem Text mit der gleichen Wahrscheinlichkeit auftreten. Das dies aber nicht der Fall ist, ist intuitiv zu erkennen, da z.b. der Buchstaben 'e' bei weitem häufiger Auftritt wie z.b. 'x' oder 'z' oder auch Satzzeichen wie '!'. Deshalb setzt man zur Kodierung eines solchen Textes mit asymmetrischen Auftrittswahrscheinlichkeiten der einzelnen Zeichen u.a. die Huffman-Kodierung ein, da diese einen Code in Abhängigkeit der Wahrscheinlichkeiten berechnet. Ziel der Huffman-Kodierung ist es also, eine Input-Sequenz anhand der Wahrscheinlichkeit der einzelnen Zeichen in einen optimalen Präfix- Code zu übersetzen. Diesem Code liegen zwei Bedingungen zu Grunde: 1) es gibt keinen anderen Code mit kürzerer, mittlerer Codewortlänge 2) kein Codewort ist Präfix eines anderen Codeworts (Fanobedingung) Die Wahrscheinlichkeit für ein bestimmtes Zeichen ist im allgemeinen, z.b. für einen deutschen Text, nicht bekannt. Deshalb behilft man sich hier mit der relativen Häufigkeit eines Zeichens in dem zu kodierenden, festen Text, welche genau dessen Wahrscheinlichkeit widerspiegelt. Die Kodierung wird im Prinzip durch einen binären Baum dargestellt, dessen Blätter alle Zeichen des Alphabetes des zu kodierenden Textes Johann Hawe, WS 2010/11 7
darstellen. Dieser kann einfach konstruiert werden, indem man jeweils die beiden Zeichen mit den geringsten Wahrscheinlichkeiten in einem neuen Knoten K vereinigt. Die Wahrscheinlichkeit für K ist dann die Summe der Einzelwahrscheinlichkeiten der beiden Zeichen. K dient dann als Basis für die weitere Konstruktion des Baumes, solange bis alle Buchstaben des Alphabetes hinzugefügt wurden. Abb.2: Huffman-Baum mit dazugehörigem Code Der Code für ein bestimmtes Zeichen kann dann einfach an dem erstellten Baum abgelesen werden, indem man den Pfad von der Wurzel bis zu dem Blatt betrachtet. Wird hier ein Knoten nach links verlassen, notiert man eine '0', wird er nach rechts verlassen notiert man eine '1' (s. Abb. 2). Auf diese Weise entsteht für einen gegebenen Text ein optimaler Präfix-Code (ohne Beweis). Zur Dekodierung kann der kodierte Text zusammen mit dem generierten Baum oder mit einer Vorschrift zur Erstellung des Baumes übertragen werden. Letzteres ist dann nötig, wenn z.b. zwei Zeichen die gleiche Auftrittswahrscheinlichkeit besitzen. Johann Hawe, WS 2010/11 8
6. Fazit Mit der Burrows-Wheeler-Transformation haben wir ein Verfahren kennen gelernt, welches in Kombination mit z.b. der Move-To-Front-Kodierung und der Huffman-Kodierung eine schnelle und effiziente Kompression verschiedener Texte ermöglicht. Ist die Implementierung der Dekompressions-Transformation noch einfach zu bewerkstelligen, so muss man bei der Kompressions-Transformation ein besonderes Augenmerk darauf legen, die Sortierung der Zeilen effizient zu implementieren, da sonst die Laufzeit des Algorithmus stark anwachsen kann. Johann Hawe, WS 2010/11 9
Literatur [1] M. Burrows, D.J. Wheeler: A Block-Sorting Lossless Data Compression Algorithm, Digital SRC Research Report No. 124, 1994. [2] V. Heun: Grundlegende Algorithmen, Vieweg-Verlag, 2003, Abschnitt 6.5. [3] Burrows-Wheeler transform, http://en.wikipedia.org/wiki/bwt, vom 6.11.2010, letzter Besuch: 15.11.2010 [4] Mark Nelson: Data Compression with the Burrows-Wheeler-Transform, Dr. Dobb's Journal, online version, http://marknelson.us/1996/09/01/bwt/ vom 1.09.1996, letzter Besuch: 15.11.2010, Johann Hawe, WS 2010/11 10