Zusammenfassung von
Die wichtigsten Information über Funktion und Bausweise lebender Organismen ist in den Sequenzen der Biomoleküle (zum größten Teil) gespeichert. Diese DNA-Sequenzen enthalten somit, mit A,C,G und T codiert, den "code of life". Eine Sequenz besteht aus einem Basis-Paar pro Buchstabe. Die Länge der DNA wird in Giga Base Pairs (Gbp) gemessen. Durch den Vergleich der Sequenzen (exakt und approximativ) will man Rückschlüsse auf die Struktur, die Funktion und das Zusammenspiel der einzelnen Komponenten ziehen. Deshalb spielt der Sequenzvergleich eine sehr große Rolle.
Viele Projekte (auch die der Autoren) befassen mit der Suche nach multiplen Genen der DNA, um Modellorganismen für Genfunktionen zu finden, die untersucht werden.
Bisher verwendete Such-Techniken sind
Die Methoden zur Bestimmung der Sequenzen von Biomolekülen haben sich in den letzten Jahren so sehr verbessert, dass man pro Tag mehrere Megabyte an Daten pro Tag produzieren kann. Große Datenmengen und große Anfragemengen überfordern jedoch die bisherigen Techniken. Die Ergebnisse sind mitunter nicht genau genug oder werden nicht schnell genug gefunden.
Das Problem liegt also bei den Algorithmen zum Sequenzvergleich bzw. -suche. Also versucht man diese zu beschleunigen. Wir benötigen eine Datenstruktur, welche das Suchen im Datensatz effizient gestaltet. Ein möglicher Ansatz, der in der Datenbankwelt generell sehr beliebt ist, ist die Verwendung von Indizes. Suffix-Trees sind eine solche Index-Struktur, die in diesem Zusammenhang große Beliebtheit erworben hat, und werden noch ausführlich vorgestellt.
Bei der Verwendung von Indizes sind jedoch folgende Schwierigkeiten zu beachten:
Im behandelten Paper wurden diverse Indices namentlich
erwähnt und (sehr knapp) diskutiert. Der Information halber
werden im folgenden die verschiedenen Indices erwähnt.
| Index | Nachteil |
|---|---|
| Inverted Files, B-Trees, Prefic Indices |
Nutzlos, da DNA nicht in Worte zerlegbar ist. |
| q-Grams | Schnell, aber liefert nur matchings mit hoher Ähnlichkeit zur Anfrage. |
| Suffix Arrays | Nutzvoll nur bis 10 Mbp. |
| Suffix Binary Search Trees | (Bisher) nur bis 50 Mbp erzeugbar. |
| LC-Tries | ? (wurde erwähnt, aber nicht diskutiert) |
| Suffix Trees | Nützlich nur für Hauptspeicher-Instanzen. |
Gegeben ist ein Datensatz DNA in Form eines Strings S der Länge n und viele weitere Sequenzen Ai der Länge mi. Wir gehen davon aus, dass die Sequenzen Ai viel kleiner als S sind, also mi << n. Gesucht wird jeweils, ob und wie häufig die Sequenz Ai in S enthalten ist.
Einen Ansatz dazu bietet z.B. der Algorithmus von Bayer-Moore, der hier jedoch nur grob beschrieben wird. Im Wesentlichen machen wir einen linearen Scan durch S und prüfen dabei ob A in S enthalten ist (ohne jedesmal durch A laufen zu müssen). Für jede Sequenz Ai hat der Algorithmus somit eine Laufzeit von O(n).
Hier greift der Vorteil von Suffix-Trees. Wir bilden zunächst in einem Preprocessing zum String S einen Suffix-Tree. Mittels Suffix-Links gelingt dies sogar in linearer Zeit, also O(n). Das Suchen von Ai kann man mittels dieses Suffix-Trees dann in O(mi) erledigen. Ist also mi << n, ist dies ein erheblicher Laufzeitgewinn beim Suchen, welcher bei genügend vielen Anfragen Ai auch das Preprocessing amortisiert aufwiegt.
Nun zur Konstruktion. Wir bilden einen Baum, dessen Kanten
Buchstaben bzw Wörter darstellen. Ein Suffix Trie zum
String S ist ein solcher Baum, bei dem alle Knoten (durch ihren
Pfad zur Wurzel) je einen Teil-String von S repräsentieren und
bei dem zu jedem Teilstring von S auch genau ein solcher Knoten
existiert.
Der Platzbedarf als auch die Konstruktionszeit beträgt hier
O(n²).

Suffix Trie
Aus diesem gewinnt man den Suffix Tree, indem wir
innere Knoten vom Grad 2 durch Kontraktion mit dem Kind
eleminieren. Dadurch wird der Baum verjüngt und enthält fast
nur noch Knoten, die Suffixe des Strings S repräsentieren.
Weiterhin merken wir uns nicht mehr in den Kanten die jeweiligen
präsentierten Buchstaben, sondern ihre Positionen im String S.
Diese Positionen bzw. Bereiche (durch Kontraktion) merken wir uns
nun in den Knoten statt in den Kanten.
Durch diese Veränderungen sinkt der Platzbedarf auf O(n). Die
Konstruktionszeit dieses Baumes ist im worst case O(n²), im
average case O(n log n).

Suffix Tree
Nun gibt es Suffixe, die als Präfix in anderen Suffixen enthalten sind. Im Beispiel wäre dies der Suffix 8. Der Knoten, der solch einen Suffix repräsentiert, ist dann kein Blatt. Mittels eines Tricks, nämlich dem vorherigen Einfügen eines Terminatorsymbols $ (welches im bisherigen Alphabet nicht enthalten war) an das Ende von S erreichen wir, dass nun kein Suffix Präfix eines anderen ist. War zuvor Suffix 8 (A) ein Präfix von Suffix 3 (ATCTTA), so ist jetzt Suffix 8 (A$) ein Präfix von Suffix 3 (ATCTTA$). Damit können Knoten, die einen Suffix von S darstellen, keine inneren Knoten mehr sein und umgekehrt stellen alle Blätter einen Suffix dar. Dadurch sind die Suffix-Knoten von den verzweigenden (inneren) Knoten getrennt und es gibt eine eineindeutig Beziehung zwischen Blättern und Suffixen.
Bspl. Suffixe von ACATCTTA mit
und ohne Terminatorsymbol
| 12345678 | 123456789 | |
| ACATCTTA | ACATCTTA$ | |
| CATCTTA | CATCTTA$ | |
| ATCTTA | ATCTTA$ | |
| TCTTA | TCTTA$ | |
| CTTA | CTTA$ | |
| TTA | TTA$ | |
| TA | TA$ | |
| A | A$ | |
| $ |
Der Suffix Tree kann mit Suffix Links erweitert werden. Der große Vorteil dieser Links ist, dass mit ihrer Hilfe der Suffix Tree in linearer Zeit erstellt werden kann, also O(n), und sie damit von großer Bedeutung sind. Der Suffix Link ist ein Pointer, den jeder innere Knoten enthält. Repräsentiert der Knoten den String aw, wobei a ein Buchstabe und w ein String ist, so zeigt sein Suffix Link auf den Knoten, der den String w darstellt.

Suffix Tree mit
Links
Ein Algorithmus, der Suffix Links benutzt, um den Suffix Tree in linearer Zeit zu konstruieren ist sehr kompliziert und soll hier nur wage skizziert werden.
Wir fügen zunächst den längsten Suffix ein, dann den nächstkürzeren usw. Im i-ten Schritt fügen wir den Suffix Si ein. Alle inneren Knoten des Baumes, die bisher eingefügt wurden, besitzen einen Suffix Link. Wir sparen, wenn wir nach dem Einfügen eines Suffixes mit Hilfe eines Links in die Einfügstelle des nächsten Suffixes in konstanter Zeit springen können. Sollten wir beim Einfügen neue innere Knoten erzeugt haben, so müssen wir deren Suffix Links in konstanter Zeit auf den entsprechenden Wert updaten.
Dazu zerlegen wir den Suffix Si in u v w taili, wobei u der erste Buchstabe ist, uvw sei der längste Präfix von Si (mit Wörtern v, w), der auch Präfix in einem bereits eingefügten Suffix ist. uv sei der längste Teil dieses Präfixes zudem ein innerer Knoten existiert. Es gibt also einen Pfad im Baum, der mit u anfängt und bei einem inneren Knoten d endet, der uv repräsentiert. Dieser Knoten hat einen Sohn, der mit Si den Präfix uvw gemeinsam hat. Also müssen wir die Kante von d zu diesem Sohn aufspalten und einen Knoten h einfügen, der uvw repräsentiert, und anschließend diesem noch einen Sohn hinzufügen, der dann uvw tail repräsentiert. Nun hat h noch keinen Suffix Link und wir müssen in die Einfügstelle des nächsten Suffix Si+1 = vwtaili gelangen. Dazu nutzen wir den Link des Knoten d, welcher uv repräsentiert und auf dessen Link auf den Knoten zeigt, der v repräsentiert. Von dort Scannen wir den Baum abwärts bis wir den Knoten finden, der vw repräsentiert. Auf diesen lassen wir den Link des Knoten h zeigen, der ja uvw repräsentiert. Damit haben wieder alle Knoten einen Suffix Link. Jetzt laufen wir weiter abwärts bis wir wieder die entsprechende Einfügstelle finden, usw.
Der Algorithmus zerfällt also in 3 Phasen. Nachdem ich den Link zum Knoten bzgl. v benutzt habe, setzt die RESCAN-Phase ein, in der ich den Knoten bzgl vw finde und den Suffix Link von h setze. Das Finden der richtigen Einfügestelle heißt SCAN-Phase. In der dritten und letzten Phase fügen wir die neuen Knoten ein und benutzen den Link zur nächsten Rescan-Ausgangspunkt. Man kann zeigen, dass durch das Benutzen des Links die Erzeugung des Baumes nur linear in n ist (Die Rescan-Phasen zusammengenommen sind nur linear und die Scan-Phasen zusammengenommen sind linear, die 3.Phase ist in jedem Schritt konstant). Natürlich ist die Konstruktion auch linear in der Größe des Alphabets |A|, da ich beim Suchen des richtigen Sohnes mitunter bis zu |A| Söhne testen muss.
Wir betrachten speziell diese Index-Struktur, da sie häufig benutzt wird. Dabei insbesondere in bekannten approximierenden Suchalgorithmen, biologischen Sequenz-Processings und vielen anderen biologischen Methoden. Sowohl der Platz als auch die Konstruktionszeit sind linear, was sehr vorteilhaft ist. Jedoch kann gezeigt werden, dass sich Suffix Trees mit Suffix Links nur eignen, solange sie in den Hauptspeicher passen. Anderenfalls entstehen durch die vertikale Baumstruktur und die horizontalen Suffix Links sehr häufig Disk-Zugriffe, wodurch die Performance in den Keller rutscht. Das gleiche Problem beim Erzeugen persistenter Suffix Trees entsteht durch die Pseudo-Zufallsstruktur der DNA, woraus Zufallszugriffe auf Disk und damit einen Menge Cache-Misses folgen.
Deshalb hat man sich mit der Erzeugung persistenter Suffix Trees auseinandergesetzt, um zumindest kleine Datensätze bearbeiten zu können. Arbeiten hierzu gibt es von
Trotzdem bedarf es einer grundlegenden Änderung, um Suffix Trees auch für große Sequenzen nutzen zu können.
Um Suffix Trees auch für große Sequenzen erzeugen und nutzen zu können, muss zum einen der erforderliche Speicherplatz minimiert werden und zum zweiten (und wesentlichen) die Anzahl Disk-Zugriffe möglichst gering gehalten werden.
Dies wird durch folgende Ideen verwirklicht:
Bei langen DNA-Sequenzen übersteigt die Größe des Baumes auch ohne Links ein Vielfaches der Hauptspeichergröße. Wir müssen ihn also zerlegen, ihn bzw. die Menge der Suffixe in Partitionen einteilen. Die Anzahl der erwarteten Partitionen ergibt sich wie folgt:
Für die Art der Partitionierung gibt es zwei Vorschläge:
Pi = ci ak-1 + ci+1 ak-2 +...+ ci+k-1 a0
wobei wir die Alphabet-Größe mit a und die Position des k-ten Zeichens von S im Alphabet mit ck bezeichnen. Dadurch entspricht Pi einer Zahl zur Basis a, deren Ziffern aus den Positionen der ersten k Buchstaben des Suffixes im Alphabet bestimmt werden. Die Suffixe sind somit "der Größe nach" geordnet, wobei ihre Zeichen als Ziffern entsprechend der Reihenfolge im Alphabet benutzt werden. Pi gibt dabei nicht die Nummer der entsprechenden Partion an. Die möglichen Werte von Pi liegen zwischen 0 <= Pi < ak - 1. Diese verteilen wir gleichmäßig auf die Partitionen, wodurch wir eine Intervallgröße von r = (ak - 1) div p erhalten. Damit gilt: Suffix i liegt in Partition j gdw. jr <= Pi < jr + r.

partitionierter
Suffix Tree
Der Algorithmus arbeitet die Partitionen der Reihe nach ab und füllt sie jeweils mit den Suffixen, die ihm zugeordnet sind. Für jeden dieser Suffixe wird ein Knoten angelegt und eingefügt. Nach erfolgreichem Auffüllen der Partition wird dieser ein Checkpoint verpasst, dh. sie wird geschlossen und während der Konstruktion nicht mehr geladen.
Creation-Algorithm For All Partitions j begin For All Suffixes i if (Suffix i is in Partition j) then begin New Node(i); Insert(i); end; CheckPoint; //Close Partition j end;
NodePtr = ObjectPointer Child: NodePtr; //erster Sohn Sibling: NodePtr; //nächstälteres Geschwister LeftIndex: Integer; //Linker Index des Kanten-Worts end;
Der Knotentyp enthält sein erstes Kind, seinen nächstälteren Bruder und den linken Index des Teilwortes, den seine Kante zum Vater präsentiert. Er enthält nicht wie üblich einen Suffix Link, die Suffix Nummer oder den rechten Wort-Index. Indem sich jeder Knoten nicht seine Kinder sondern einen seiner Geschwister merkt, müssen die Knotenobjekte nicht dynamisch angelegt werden, sondern haben eine feste Größe. Diese setzt sich zusammen aus
dh. 36 Byte pro Knoten.
Für einen String der Länge n werden im Baum zwischen 1.6n und 1.8n Knoten angelegt. Damit ergibt sich bei einer Knotengröße von 36 Bytes eine Gesamtgröße von 58n bis 65n Bytes, dh bis zu 65 Bytes pro Buchstabe. Eine Programmierung ohne Objekte benötigt nur 12 Bytes pro Knoten, was nur 21 Bytes pro Buchstabe entspricht. Mit Hilfe der Kompression von Kurtz benötigt man nur 13 Byte je Buchstabe.
Beim Anlegen des Knotens setzten wir Child und Sibling auf Null, den LeftIndex setzen wir auf die Nummer des Suffixes Si. Das Einfügen setzt sich zusammen aus dem Suchen der passenden Stelle und dem direkten Einfügen des Knotens.
Die Suche startet bei der Wurzel, und geht abwärts entsprechend jeweils dem Kind, welches einen Prefix von Si repräsentiert. Die Suche liefert den Knoten zurück, dessen String der längste repräsentierte Prefix von Si ist. Der LeftIndex soll den linken Index des Strings wiedergeben, den die Kante zum Vater repräsentiert. Während der Suche wird dieser Wert entsprechend der bereits vorhandenen Baumstruktur aktualisiert.
Beim Einfügen gibt es zwei Arten.

Insert Routine
Die Suche beginnt bei der Wurzel und läuft abwärts entsprechend dem Kind, welches einen Prefix mit einem Prefix von Si gemeinsam hat, der länger ist als der des Vaters. Die Suche verläuft entweder erfolglos oder liefert den Knoten mit dem kürzesten String zurück, der den gesuchten String vollständig als Prefix enthält.
Nun wird der Teilbaum, der den gefundenen Knoten als Wurzel enthält, vollständig durchlaufen und als Ergebnis werden alle Suffixe zurückgegeben, die innerhalb dieses Teilbaum repräsentiert werden. Die Suffix Nummern, welche nicht explizit innerhalb der Knoten gespeichert sind, werden während des Durchlaufens des Baumes berechnet anhand der Länge der jeweils präsentierten Strings. Ebenso ergeht es dem RightIndex, der den rechten Index des durch eine Kante präsentierten Strings enthält. Dieser ist für Blätter nämlich immer gerade n, da die Blätter genau die Suffixe darstellen. Bei den inneren Knoten berechnet man den RightIndex aus dem LeftIndex des ältesten Kindes durch Subtraktion von 1.
Der Aufwand für das Suchen setzt sich aus der Länge k der Anfrage und aus der Anzahl der Matchings m innerhalb des abzusuchenden Teilbaumes zusammen, also O(k + m). Die zu erwartende Größe des zurückgelieferten Ergebnisses kann ebenfalls berechnet werden. Wenn a die Größe des Alphabets ist, so gibt es a^k verschiedene Wörter mit Länge k. Folglich wird bei einer Anfrage der Länge k ein Teilbaum der Größe 1/a^k des Gesamtbaumes zurückgeliefert, wenn wir aufgrund der Zufallsverteilung der DNA auf eine Balance des Baumes schließen. Sind die Anfragen kurz, so kann dies sehr viel sein und somit das Benutzen des Indexes Overhead bedeuten. In diesem Fall mixt man die Algorithmen, dh. für kleine Anfragen benutzt man weiterhin den Seriellen Scan der Daten, für größere Anfrage (k > 10) den Index.
Getestet wurden die Erzeugungszeiten von Suffix Trees mit und ohne Links. Die Zeitkomplexität bei verlinkten Suffix Trees ist O(n), bei nicht verlinkten im worst case O(n²). Da jedoch die DNA eine Pseudo-Zufallsstruktur hat, ergibt sich ein average case von O(n log n). Nun können verlinkte Suffix Trees nur für "kleine" DNA-Längen (25 Mbp) persistent erzeugt werden und in dieser Größenordnung ist im Zeitverhalten kein Vorteil gegenüber unverlinkten Suffix Trees festzustellen.
Anfrage gleicher Länge werden zu Batches zusammengefasst. Dadurch macht es sich die Ausnutzung des Cache bemerkbar. Tests auf "warm store" schnitten sehr viel besser ab.
Das Auswerten einer Anfrage setzt sich wie oben beschrieben aus zwei Teilschritten zusammen. Zum einen dem Suchen des entsprechenden Knotens, was unabhängig der Anfragelänge sehr schnell geht. Zum anderen jedoch muss bei erfolgreichem Finden dieses Knotens der gesamte Kinderbaum durchsucht werden. Dies sind jedoch bei kurzen Anfrage sehr große Bäume mit vielen Ergebnissen. Folglich benötigt die Auswertung von kurzen Anfragen hier sehr viel mehr Zeit als die gleiche Menge von längeren Anfrage.
Das wars.
[1]
E.Hunt, M.P.Atkinson, R.W.Irwing, A Database Index to Large
Biological Sequences, Univ. of Glasgow G12 8QQ, UK2001
[2]
P.Bieganski, Genetic Sequence Data Retrieval and Manipulation
based on Generalized Suffix Trees, PhD thesis, Univ. of
Minnesota, USA 98
[3]
R.Baeza-Yates, G.Naravvo, A Hybrid Indexing Method for
Approximate String Matching;
R.Baeza-Yates, G.Naravvo, A new indexing method for
approcimate string matching, in CPM99, LNCS 1645, p 163-185, 1999
[4]
M.Farach, P.Ferragina, S.Muthukrishnan, Overcoming the Memory
Bottleneck in Suffix Tree Construction, In FOCS98, p 174-185,
1998
[5]
S.Kurtz, Reducing the space requirement of suffix trees Softw.
Pract. Exp. 29:1149-1171, 1999