Clustering on Intel MIC with Huge Datasets Andreas Bauer, Gregor Daiß, Max Franke Institut für Parallele und Verteilte Systeme Universität Stuttgart Zusammenfassung Due to the spread of cheap and accurate sensors, huge amounts of data are generated in relatively short times. To process these amounts of data, special hardware and special algorithms which utilize the available hardware efficiently are required. One interesting device is the Xeon Phi co-processor based on the Intel many integrated Cores Architecture. These are very efficient for highly parallel code. In this Project we have implemented a clustering algorithm developed by Benjamin Peherstorfer, as described in [PPB12], to run it on a Xeon Phi. The algorithm is divided into two sub-algorithms. The first one is an algorithm to compute a similarity graph and the second one calculates a density estimation based on a sparse grids. Both sub-algorithms have a structure that can easily be split into a high amount of threads and therefore this algorithm makes good use of the possibilities of the Xeon Phi. The data we processed with this algorithm consists of solar data. It’s uncompressed size is roughly 2.5 Terabyte. We pre-processed the data for better use and to get rid of redundant data. 1 Das Clustering-Verfahren Wir haben für diese Arbeit den Clusteringalgorithmus aus [PPB12] sowohl für eine CPU mit mehreren Kernen, als auch für Intel Xeon Phi Beschleuniger-Karten implementiert. Der Algorithmus dient dazu, Cluster – also Anhäufungen von Datenpunkten – in einer Menge von Daten zu finden. Hierzu wird eine dünngitterbasierte Dichteschätzung mit einem Algorithmus zu Nachbarschaftssuche kombiniert. Der Algorithmus wird, wie in [PPB12] beschrieben, in 4 Schritte aufgeteilt: 1. Erstellen eines Nachbarschafts-Graphen zur Repräsentation der Datenpunkte 2. Berechnung der Dichteschätzung f mit Hilfe der Dünngitter basierten Dichteschätzung 3. Entfernen der Knoten und adjazenten Kanten, auf denen die Dichtefunktion unter einen Schwellwert fällt 4. Zuordnung der übrigen Knoten zu den entsprechenden Clustern Bei dem Algorithmus zur Nachbarschaftssuche haben wir den k-nearest-neighbors Algorithmus aus [Bis06] verwendet. Hierbei wird ein Graph erzeugt, bei dem jeder Knoten mit seinen k nächsten Nachbarn verbunden ist, wobei ein Knoten im Graph einen Datenpunkt in der Eingabe darstellt. Aus dem konstruierten Graph werden basierend auf der Dichtefunktion Knoten, die in Bereichen geringer Dichte liegen, mit allen adjazenten Kanten entfernt. Anschließend werden die Knoten einer Zusammenhangskomponente markiert, um die verschiedenen Cluster zu kennzeichnen. 1.1 Intel Xeon Phi Die Zielarchitektur, auf der der fertige Algorithmus laufen soll, ist die Xeon Phi von Intel. Dabei handelt es sich um einen Coprozessor, der speziell für hoch-parallelisierten Code entwickelt wurde. Bei voller Auslastung schafft ein einzelner Coprozessor bis zu 1,2 Teraflops bei doppelter Genauigkeit. Zur Programmierung des Xeon Phis haben wir OpenCL benutzt. 2 2.1 Dichteschätzung Ansatz Wir bereits in der Einführung erwähnt, brauchen wir zum Clustering der Datenpunkte mithilfe des Graphalgorithmus zunächst eine Schätzung der Dichte. Um diese zu berechnen, benötigen wir aufgrund der Größe des Datensatzes einen Algorithmus, der auch bei großen Datensätzen effizient ist. Eine Möglichkeit, das zu erreichen, wäre die Dichteschätzung mit einem Gitter an Basisfunktionen, allerdings wachsen diese Gitter exponentiell mit der Anzahl der Dimensionen. Bei einem hochdimensionalen Datensatz ist dieser Ansatz so also nicht ideal, auch wenn die Laufzeit nur von der Gridgröße abhängen würde. Dies nennt man Fluch der Dimensionalität. Um die Laufzeit zu begrenzen, nutzen wir den Algorithmus von [Pfl10] auf dünnen Gittern. Bei diesem versuchen wir nur die Gridpunkte eines vollen Gitters zu reduzieren, indem wir nur die Gridpunkte beachten, die am meisten zur entgültigen Lösung beitragen. Wir opfern also etwas Genauigkeit, da wir weniger Gridpunkte nutzen, bekommen aber eine bessere Laufzeit des Algorithmus. 2.2 Dünne Gitter Ein Gitterpunkt eines vollen Gitters wird durch jeweils einen Level lj und einen Index ij pro Dimension j identifiziert. Mit diesen Werten kann man die Basisfunktion eines beliebigen Gitterpunkts berechnen: ϕ(x) = max(1 − |x|, 0) (2.1) ϕl,i = ϕ(2l x − i) (2.2) Diese Funktion liegt auf dem Gitterpunkt der an der Stelle xl,i = 2−l i. Diese Basisfunktionen werden im d-dimensionalen Fall einfach mit einem Tensorprodukt erweitert. ϕ~l,~i := d Y j=1 2 ϕlj ,ij (xj ) (2.3) Die entgültige Dichteschätzung des Gitters ergibt sich dann aus der Summe der gewichteten Basisfunktionen der Gitterpunkte, wobei N die Anzahl der Gitterpunkte ist. u(~x) = N X αk ϕ~l,~i (~x) (2.4) k=0 Um eine Dichteschätzung zu erhalten, müssen wir also den Vektor α bestimmen. Der mögliche Funktionsraum wird durch eine Reihe von Subräumen W~l festgelegt, welche jeweils den möglichen Funktionsraum eines Levels (oder im d-dimensionalen Fall eines Vektors an Leveln) darstellen. I~l sind die Indexmengen der Unterräume W~l, welche wie folgt festgelegt sind: I~l = {i : 1 ≤ ij ≤ 2lj − 1, ij odd, 1 ≤ j ≤ d} (2.5) o n W~l = span ϕ~l,~i (~x) : ~i ∈ I~l (2.6) Bei einem vollen Gitter der Tiefe n erhält man nun einen Funktionsraum: Vn = ⊕l≤n W~l (2.7) Bei einem dünnen Gitter bleibt die Definition der Unterräume gleich, allerdings werden nun weniger davon genutzt. Vn = ⊕|~l|≤n+d−1 W~l (2.8) Die Abbildung 1 zeigt, wie solche Gitter aussehen. Im Folgenden benutzen wir N als die Anzahl der Gitterpunkte, wobei n die Tiefe des Gitters bezeichnet. Die Tiefe des Gitters bestimmt den maximalen Wert, den der Level eines Gitterpunkts annehmen kann. Bei dünnen Gittern kann man die Anzahl der Gitterpunkte mit der Formel d−1 O(h2n (log h−1 ) abschätzen, wobei hn = 2−n gilt. Bei einem vollen Gitter wäre die n ) Anzahl der Gitterpunkte hingegen h−d n , wir haben die Anzahl der Gitterpunkte also deutlich verringern können. Den angesprochenen Verlust der Genauigkeit ist in [Pfl10] näher beschrieben. 2.3 Herleitung Die Aufgabe ist also, den Vektor α so zu bestimmen, dass die Funktion 2.4 eine Schätzung der Dichte ergibt. Der Funktionsraum, aus dem diese Funktion stammt, bezeichnen wir ab sofort gemäß [Pfl10] Vn . Die Idee ist, zunächst eine initiale Schätzung f für die Dichteschätzung aufzustellen. Von dieser Funktion ausgehend will man nun eine geglättete Funktion bekommen, die noch möglichst nah an der Schätzung ist aber dennoch glatter ist. Als Variationsproblem erhält man so folgende Gleichung: Z Z 2 (u(x) − f ) dx + λ (Lu)2 dx (2.9) Ω Ω 3 Abbildung 1: Dreidimensionales Gitter der Tiefe 6 R Der Term Ω (u(x) − f )2 dx bewirkt, dass möglichst gering von der inR die Abweichung 2 itialen Dichteschätzung ist. Der Term λ Ω (Lu) dx wird Regularisierungsterm genannt. Durch diesen wird bestimmt, wie glatt die Ergebnisfunktion der Variationsgleichung wird. Dies wird über den Regularisierungs Parameter λ bestimmt. Unser Variationsproblem sieht nun also wie folgt aus: Z Z 2 2 ((u(x) − f ) + λ(Lu) )dx = P dx Ω (2.10) Ω Nach [PPB12] erhält man damit folgendes Gleichungssystem: (A + λC)α = 1 T B N (2.11) Ai,k = (ϕi , ϕk )L2 (2.12) Ci,k = (Lϕi , Lϕk )L2 (2.13) Bi,j = ϕi xj (2.14) Wenn man dieses Gleichungssystem nach α auflöst, erhält man die gesuchte Dichtefunktion u(x). 4 2.4 Analyse der Dichtegleichung (2.11) RegularisierungstermR Um die Matrix C zu konkretisieren, müssen wir noch den Regularisierungsterm λ Ω (Lu)2 dx wählen. Wir nutzen den in ?? vorgeschlagene AnP 2 satz des Regularisierungsterm N i=1 αi . Es wurde gezeigt, dass dieser eine gute Wahl für die hierarchische Basis eines dünnen Gitters, ist. Die Vorteile dieses Terms werden wir am Ende der Herleitung sehen, da eine der Matrizen, mit denen wir rechnen zur Identitätsmatrix wird. Rechte Gleichungsseite Dieser Teil ist in O(N d) berechenbar. Allerdings ist der Speicherbedarf O(N ), daher ist es ausreichend, diesen Teil nur einmal zu berechnen und abzuspeichern. Linke Gleichungsseite Die Größe der Matrix A ist offenbar N 2 , wobei N die Anzahl der Gridpunkte ist. Die Größe eines Grids lässt mit der Formel abbschätzen. Für große und genau aufgelöstete Grids steigt die Größe also sehr schnell an, daher kann man diese Matrix nicht unbedingt explizit abspeichern, da der Speicherbedarf O(N 2 ) zu hoch ist. Die Matrix muss also implizit dargestellt werden, das heißt, man muss bei jeder Matrix-Vektor Multiplikation die entsprechenden Matrixeinträge erneut berechnen. Dies wird die meiste Rechenzeit beanspruchen, daher wird im nächsten Kapitel detailiert auf die Parallelsierungsmöglichkeiten und OpenCL-Implementierung der Matrix-Vektor-Multiplikation eingegangen. Lösungsalgorithmus Zum Lösen der Gleichung 2.11 verwenden wir direkt den Lösungsalgorithmus der konjugierten Gradienten aus der SGpp-Library. 2.5 Parallelisierung Die naheliegendste Ansatz ist es, das Matrix-Vektor-Produkt der Matrix A + λC zu parallelisieren, da man über den Ergebnisvektor der Multiplikation parallelisieren kann und diese Operation zum Lösen der Gleichung 2.11 oft verwendet werden muss. Brute Force Kernel Unsere erste Realisierung der Parallelisierung des MatrixVektor-Produkts besteht aus einem OpenCL-Kernel, der die Abbildung 3 umsetzt. Ein Thread berechnet exakt einen Eintrag vom Ergebnisvektor des Matrix-VektorProdukts. Da die Matrix A, wie bereits erwähnt, nicht abgespeichert werden kann, muss jeder Thread in der Lage sein, das komplette dünne Gitter durchzugehen um die Einträge einer Zeile der Matrix zu berechnen. Das bedeutet der OpenCL-Kernel benötigt die Level und Indices der einzelnen Gridpunkte, sowie natürlich den α-Vektor, mit dem die Matrix multipliziert wird. Diese Werte werden im OpenCL-Kernel als globale Arrays behandelt. Der Vorteil daran ist, dass jeder Thread im Code einfach darauf zugreifen kann. Allerdings muss jeder 5 Thread selbst alle Werte auslesen, was dazu führt, dass unnötig viele Leseoperationen durchgeführt werden. Zusätzlich übergeben wir in der Implementierung noch λ als Skalar an den Kernel, sodass die Einträge der Matrix direkt als A + λC entsprechen. Ein Performancetest, bei dem wir die Multiplikationsgeschwindigkeit auf der MIC getestet haben, ist in 2 zu sehen. Abbildung 2: Performance des Kernels Streaming-Kernel Bei der Beschreibung des Brute-Force-Kernels sprachen wir an, dass unnötig viele Leseoperationen durchgeführt werden, da jeder Thread unabhängig von den anderen auf die Daten zugreift. Dieses Problem lässt sich durch die Nutzung von Workgroups in OpenCL lösen. Eine Workgroup besteht aus einer gewissen Anzahl von Threads, die ihre Daten mithilfe von locale arrays austauschen und sich mit Barriers synchron halten lassen. Die Funktionsweise dieses Kernel entspricht ungefähr der des Brute-Force-Kernels. Auch hier wird pro Thread ein Eintrag des Ergebnisvektors berechnet. Allerdings wartet ein Thread i mit der Berechnung von (A + λ)t,i αi , bis auch die anderen Threads derselben Workgroup den Eintrag mit dem Spaltenindex i berechnen. Nun lädt jeder 6 Abbildung 3: Schema des Brute-Force-Kernels Thread die Level und Indizes seines momentanen Gridpunkts in den lokalen Speicher. So können die anderen Threads der Workgroup nun ebenfalls auf die Daten dieses Gridpunkts zugreifen, ohne diese noch einmal aus dem globalen Speicher laden zu müssen. Eine Workgroup arbeitet die Matrix also quasi in kleinen Quadraten ab (zu sehen in der Abbildung 4 ). Sobald alle Threads einer Workgroup ein Quadrat abgearbeitet haben, beginnt ein Neues und die Threads fangen wieder an, den lokalen Speicher mit ihrem momentanen Gridpunkt zu überschreiben. Wenn man nun beispielsweise, wie in der Abbildung 4 eine Workgroup der Größe 2 hat, bedeutet es, dass im lokalen Speicher zu Beginn eines Quadrats die Daten zu 2 Gridpunkten sind. Daher können die Threads 1 und 2 direkt 2 Matrixeinträge berechnen und verarbeiten, ohne Daten aus dem globalen Speicher nachladen zu müssen. Je größer also die Workgroups sind, desto mehr Daten können im lokalen Speicher von anderen Threads wiederverwendet werden. Dieses Prinzip funktioniert, da die Matrix symmetrisch ist. Zur Berechnung des Eintrags an der Stelle (i, j) benötigt man, genauso wie an der Stelle (j, i), die Daten des Gridpunkts i. Zuvor mussten O(2dN 2 ) Leseoperationen auf dem globalen Speicher des Kernels durchgeführt werden, da jeder Threads unabhängig von den Anderen alle Daten zu den 2dN 2 ) LeseopeGridpunkten laden musste. Nun benötigt man nurnoch O( W ORKGROU P SIZE rationen, da sich einige Daten im lokalen Speicher befinden. Die Performancesteigerung dieses Kernels hängt also stark davon ab, um wieviel schneller der Speicherzugriff auf den lokalen Speicher im Vergleich zum Zugriff auf den globalen Speicher ist. Der Einfachheit halber haben wir jedoch im Folgenden den Brute-Force-Kernel verwendet. 7 Abbildung 4: Schema des Streaming-Kernels Data Kernel Mit den vorherigen 2 Kernels haben wir die linke Seite der zu lösenden Dichtegleichung bereits erfolgreich parallelisiert. Allerdings ist es sinnvoll, auch die rechte Seite der Gleichung parallel zu berechnen, da für große Gitter und eine hohe Anzahl von Datenpunkten hier sonst ein Flaschenhals entsteht, obwohl man das Ergebnis zum Lösen der Gleichung nur einmal berechnen muss. Es müssen lediglich wieder die Level und Indices der Gridpunkte (zur Berechnung von ϕ(x)), sowie die Datenpunkte selbst in den Kernel geladen werden. Jeder Thread kann dann PNwieder einen Eintrag des Ergebnis Vektors berechnen, gemäß der Formel 1 fj = M i=1 ϕj (xi ), wobei ϕj die Basisfunktion des Gridpunkts j ist. 3 3.1 k-nearest-neighbors Aufbau des Graphen Der erste Schritt des Algorithmus besteht aus der Konstruktion eines NachbarschaftsGraphen zur Repräsentation der Datenpunkte. Hier haben wir uns für die Brute-Force- 8 Abbildung 5: Schema des Data-Kernels Variante des Algorithmus entschieden, da diese besonders gut parallelisierbar ist. Bei dem verwendeten Nachbarschafts-Graphen handelt es sich um einen gerichteten ungewichteten Graphen. Die Konstruktion des Graphen erfolgt, wie in [Bis06] beschrieben. Dieser Algorithmus hat eine Laufzeitkomplexität von O(d · n2 ), wobei d die Dimension und n die Anzahl der Datenpunkte darstellt, da für jeden Punkt der Abstand von diesem zu jedem anderen Punkt berechnet werden muss, was jeweils O(d) Operationen benötigt. 3.2 Elimination von Knoten/Kanten In Abhängigkeit von dem gewählten k ist der Graph mehr oder weniger zusammenhängend, zumindest jedoch sollte das k groß genug sein, dass alle Punkte, die dem selben Cluster zugeordnet werden können, in der gleichen Zusammenhangskomponente repräsentiert sind. Das Ziel der Knotenelimination ist es, dass jedes Cluster im Graph durch eine Zusammenhangskomponente dargestellt wird. Da nicht jeder Knoten einem Cluster zugeordnet werden kann, müssen zu diesem Zweck einzelne Knoten sowie adjazente Kanten entfernt werden. Dazu wird die errechnete Dichtefunktion auf jedem Knoten ausgewertet und diejenigen Knoten, bei denen das Ergebnis unter einen Schwellwert fällt, sowie deren adjazente Kanten, werden aus dem Graph entfernt. Bei vielen Problemen reicht dies schon aus, um alle Cluster zu erkennen. Nahe beieinander liegenden Clustern können jedoch Verbindungen untereinander haben. Da jedoch beide Knoten zu einem Cluster gehören, würde keiner der Knoten entfernt werden und die beiden Cluster würden als eines erkannt. Um dies zu vermeiden, kann man die Dichtefunktion zusätzlich auf den Kanten 9 zwischen den Knoten auswerten und damit einzelne Kanten aus dem Graph entfernen, ohne dass Knoten entfernt werden müssen. Um höhere Genauigkeit zu erreichen, kann man die Auswertung auch an mehreren Stellen auf einer Kante vornehmen. 4 4.1 Implementierung k-nearest-neighbors k-nearest-neighbors Der Graph wird durch eine Adjazenzliste dargestellt, die wir durch einen Vektor von integer-Arrays implementiert haben. Jeder Eintrag in dem Vektor repräsentiert einen Knoten und jeder Wert in den integer-Arrays repräsentiert eine Kante. Dabei ist jeder integer-Wert der Index des Knotens, auf den diese Kante zeigt. Die Eingabe besteht aus einem Vektor von Datenpunkten v, sowie der Anzahl an Dimensionen d und einem k. v enthält dabei m double-Werte, von denen immer d zusammen einen Datenpunkt bilden, d.h. v enthält n = md Datenpunkte. v ist wie folgt aufgebaut: x1,1 , x1,2 , x1,3 , . . . , x1,d , x2,1 , x2,2 , x2,3 , . . . , x2,d , . . . In einer Schleife über alle Datenpunkte wird für jeden Punkt zunächst ein Eintrag in der Adjazenzliste erzeugt, danach werden die adjazenten Kanten berechnet. Da wir einen ungewichteten, ungerichteten Graph erstellen, ist das Hinzufügen von Kanten zu einem Knoten unabhängig von allen anderen, da in der Adjazenzliste nur ausgehende Kanten gespeichert werden. Dadurch, und durch die Verwendung der Brute-Force-Variante, ist ein besonders hoher Grad an Parallelität erreichbar. Genauer gesagt kann jede Position der Adjazenzliste in einem eigenen Thread berechnet werden. Dies ist aus zwei Gründen möglich: Zum einen durch die Verwendung eines gerichteten Graphen, zum anderen sind bei der Berechnung der nächsten Nachbarn nur Lesezugriffe auf die gemeinsamen Daten nötig. Bei den gemeinsamen Daten handelt es sich um v. Da zur Suche der Nachbarn nur n − 1 mal eine euklidsche Distanz berechnet werden muss, wozu nur Leseoperationen auf v durchgeführt werden, ist die Nachbarschaftssuche pro Knoten unabhängig. Des weiteren schreibt jeder Thread an eine andere Stelle der Adjazenzliste, wodurch auch hier kein Konflikt entstehen kann. 4.2 OpenCL-Kernel Die OpenCL-Implementierung ist nach folgendem Prinzip aufgebaut: Das heißt, für jeden Datenpunkt wird ein eigenes work item verwendet. 4.3 Kantenentfernung Das Entfernen von Knoten und Kanten kann ebenfalls recht gut parallelisiert werden. Auf jedem Knoten kann, unabhängig von anderen Knoten, überprüft werden, ob dieser entfernt werden muss oder nicht. Das Entfernen besteht in der Implementierung 10 Abbildung 6: Schema des Graph-Kernels darin, dass an der entsprechenden Stelle in der Adjazenzliste alle Einträge auf -1 gesetzt werden. Da es sich um einen gerichteten Graphen handelt und keine Kanten, die auf gelöschte Knoten zeigen, existieren dürfen, muss im Anschluss die Adjazenzliste durchlaufen und alle Kanten zu dem gelöschten Knoten entfernt, d.h die entsprechenden Array-Einträge auf -1 gesetzt werden. Bei diesem Vorgang ist es möglich, dass zwei oder mehr verschiedene Threads die gleiche Kante löschen wollen und dadurch mehr als ein Schreibzugriff auf die selbe Stelle stattfindet. Dies ist jedoch kein Problem, da jeder der Threads die gleiche Zahl an diese Stelle schreibt. Einige Probleme machen das Löschen von einzelnen Kanten notwendig, wie das Beispiel in Abbildung 7 zeigt. Dieser Datensatz enthält zwei Cluster, wobei jeder der vorhandenen Datenpunkte ein Teil eines Clusters ist. Dadurch ist ein Löschen von Knoten zur Clusterisolierung nicht möglich, wie man mit geplotteter Dichte erkennen kann (siehe Abbildung 8). Wenn bei dieser Eingabe das k klein genug gewählt ist, werden die beiden Cluster trotzdem erkannt. Bei einem etwas zu großen k werden jedoch Kanten hinzugefügt, welche die beiden Halbmonde verbinden. Eine Auswertung der Dichtefunktion auf einem Punkt auf dieser Kante würde ein Ergebnis unterhalb des Schwellwerts ergeben, die Auswertung auf dem entsprechenden Knoten jedoch nicht. 5 5.1 Vorverarbeitung des Datensatzes Der Datensatz Ursprung Der Datensatz wurde vom Institut für Photovoltaik an der Universität Stuttgart in Zusammenarbeit mit der Technischen Universität in Hamburg-Harburg 11 Abbildung 7: Mond ohne Anzeige der Dichte erstellt. Er enthält eine Vielzahl verschiedener Messdaten, die über den Lauf von acht Jahren an drei Standorten aufgenommen wurden. Der erste Standort[Zin10] ist Stuttgart. Wir haben uns bei unserem Projekt auf die Messdaten des Stuttgarter Standortes beschränkt, da die drei Teildatensätze vollkommen unabhängig voneinander betrachtet werden können. Die beiden anderen Standorte sind Nikosia in Zypern und Kairo in Ägypten, deren Daten wir aber nicht verarbeiet haben. Für mehr Hintergrundinformationen zur Datenerfassung, wie der verwendeten Speichertechnik oder der Motivation hinter manchen der gemessenen Datentypen, verweisen wir auf die Diplomarbeit[Adl13] von Hendrik Adler. Format Der Datensatz wurde uns als komprimierter Dump übergeben, also im Grunde als gigantisches CSV. Komprimiert hatte er eine Größe von etwa 200 Gigabyte. Dieser Dump wurde entpackt und in eine PostgreSQL-Datenbank eingespielt. In der Datenbank nahm er etwa 2.5 Terabyte (2500 Gigabyte) Speicherplatz ein. Der Datensatz bestand aus einer Tabelle, die für jedes Paar von Zeitstempel und SensorID einen 12 Abbildung 8: Mond mit Anzeige der Dichte Wert enthielt. Daneben enthielt er noch weitere Tabellen mit Statistikwerten zu den Daten, die für uns nicht relevant waren. Größe und Zeitspanne Der Datensatz erstreckt sich von Anfang Dezember 2006 bis Mitte/Ende November 2014. Es wurde für jede Sekunde ein Datenwert angelegt. Allerdings sollte beachtet werden, dass diese Werte oft minutenlang gleich bleiben. Das liegt an dem Eventsystem, was für die Datenerfassung verwendet wurde[Adl13][WA]. In der Datenbank wurden die Daten in einer dreispaltigen, unindizierten Tabelle abgespeichert. Diese Tabelle hatte die Spalten timestamp, sensorid und value. Somit existierten für jeden Zeitpunkt 111 Tupel in der Tabelle. Da die Tabelle nicht indiziert war, wurde das Abfragen aller Werte sehr zeitaufwändig. Auf einer Tabelle ohne Index muss für jede Abfrage die gesamte Tabelle durchsucht werden. Somit dauerten auch schon scheinbar sehr einfache Abfragen im Bereich von 4 bis 6 Stunden. Das war für uns, da wir die Werte sowieso immer nach Zeitpunkt gruppiert benutzen würden, eine sehr gute Motivation, den Datensatz umzuformen in ein Format, das für Abfragen deutlich effizienter ist. Eine weitere Motivation war Speicherplatz: Für die Universität Stuttgart wurden die Daten von 111 Sensoren verwendet. Die Daten wurden in der Datenbank mit dem PostgreSQL-Datentyp REAL abgespeichert. Dieser Datentyp ist eine 32-Bit-Fließkommazahl, oder 4 Byte. Der Zeitstempel war 13 vom Typ TIMESTAMP WITHOUT TIME ZONE, der 8 Byte groß ist. Die SensorID hat den Type SMALLINT, was eine 2-Byte-Ganzzahl ist. Somit hatte jedes Tupel eine Größe im Speicher von 8B + 2B + 4B = 14B Der Datensatz erstreckt sich über groß acht Jahren, somit existieren in der Datenbank 8 · |{z} 365 · |{z} 24 · |{z} Jahre Tage Stunden · 60 · 60} | {z ≈ 28 · 109 111 |{z} Minuten, Sekunden Anzahl Sensoren Tupel. Damit hatte die Datenbank überschlagsweise eine Größe von 14B · 28 · 109 ≈ 400GB Tatsächlich war die Datenbank sogar 2.5TB (2.5 · 1012 Byte) groß, da noch die Daten von zwei anderen Standorten und einige für uns nicht relevanten anderen Tabellen enthalten waren. Ein Zeitpunkt nahm nach dem alten Speichersystem 111 · 14B = 1544B Speicherplatz ein. Wir haben uns ein neues Speichersystem überlegt, in dem ein Zeitpunkt durch genau ein Tupel in der Datenbank repräsentiert wird. Dabei werden die Sensoren in eigene Zeilen gespeichert. Dadurch nimmt ein Zeitpunkt nur noch 8B |{z} Zeitstempel + · 111 |{z} 4 |{z} = 452B Anzahl Sensoren Größe von REAL Platz ein. Damit hatte die Datenbank noch eine Größe von 452 B · 8a ≈ 114GB s Diese Größe haben wir mit der ummodellierten Datenbank auch tatsächlich erreichen können. 5.2 Umformatierung des Datensatzes Wie in 5.1 bereits erklärt wurde, war der ursprüngliche Datensatz in einem für unsere Zwecke sehr ungünstigem Format abgespeichert, in dem Datenbankabfragen wegen ihrer Dauer nicht vertretbar waren. Daher haben wir uns dafür entschieden, die Datenbank für unsere Zwecke von Grund auf umzustrukturieren. In der alten Tabelle waren einem Zeitpunkt 111 verschiedene Tupel zugeordnet, in denen jeweils der Sensor angegeben war, von dem sie stammten. Das ermöglichte es, 14 SensorID Gemessene Größe Einheit 11 Windgeschwindigkeit 12 Windrichtung 13 Zimmertemperatur1 m · s−1 14 15 16 17 18 19 20 Modul 4 Gleichspannung Gleichstrom Modultemperatur Gleichstromleistung Gleichstromenergie Wechselstrom-Wirkleistung Wechselstromenergie 21 22 23 24 25 26 27 28 29 30 Lufttemperatur (außen) Solareinstrahlung (Pyrano2 ) Solareinstrahlung 1 Solareinstrahlung 2 Solareinstrahlung 3 Solareinstrahlung 4 Solareinstrahlung 5 Solareinstrahlung 6 Solareinstrahlung 7 Solareinstrahlung 8 31 bis 37 38 bis 44 45 bis 51 52 bis 58 59 bis 65 66 bis 72 73 bis 86 87 bis 93 94 bis 100 101 bis 107 108 bis 114 115 bis 121 Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Messwerte Modul Modul Modul Modul Modul Modul Modul Modul Modul Modul Modul Modul 5 6 7 8 9 10 11 12 13 14 15 16 ◦ ◦ C V A ◦ C W kWh W kWh ◦ C W · m−2 W · m−2 W · m−2 W · m−2 W · m−2 W · m−2 W · m−2 W · m−2 W · m−2 s. s. s. s. s. s. s. s. s. s. s. s. 14 14 14 14 14 14 14 14 14 14 14 14 bis bis bis bis bis bis bis bis bis bis bis bis 20 20 20 20 20 20 20 20 20 20 20 20 Tabelle 1: Beschreibung der Sensoren des Standorts Stuttgart. Jeweils sieben Sensoren beschreiben ein Solarmodul, außerdem wurden noch Sonneneinstrahlung und Winddaten sowie Temperatur gemessen. 15 dass Werte unterschiedlicher Sensoren unabhängig voneinander gesammelt und der Datenbank zugeführt werden konnten. Da das Messsystem so entworfen wurde, dass die Daten nur einmal täglich, oder sogar noch seltener, von den Messstationen abgeholt werden mussten, hat diese Datenbankstruktur durchaus Sinn gemacht. Um später mit den Daten zu arbeiten, war die Struktur allerdings vollkommen ungeeignet. Die neue Struktur sah es vor, für jeden Sensor eine Spalte in der Datenbank anzulegen. Dadurch sähe eine Zeile der Datenbank etwa so aus: 28723545, 2013-05-27 12:56:01, 3.417, 35.4, 20.114, ..., 205.114, 0; Dadurch wurden pro Sekunde in den 8 Messjahren 110 Werte für die SensorID (220 Byte) und 110 Werte für den Zeitstempel (880 Byte) eingespart. Bei grob 252288000 Sekunden fiel das extrem ins Gewicht, wodurch die Datenbank auf eine angenehmere Größe von etwa 100 Gigabyte reduziert werden konnte. Durch die Indizierung der Tupel und die drastisch (Faktor 100) reduzierte Anzahl von Tupeln wurde auch die Zugriffszeit auf Werte aus der Datenbank deutlich niedriger. Während eine kleine Anfrage auf die alte Datenbank bereits über vier Stunden gedauert hat, ist eine Abfrage auf die neue Datenbank in zwischen 20 Sekunden und fünf Minuten fertig, je nach Komplexität der Anfrage. Die Umstrukturierung selber wurde monatsweise durchgeführt, da schon ein Monat ein Datenvolumen im zweistelligen Gigabyte-Bereich bedeutet. Eine Query zum Auswählen aller Werte für einen Monat kann in Listing 1 gesehen werden. Listing 1: Auswahl eines Kalendermonats −− Werte A p r i l 2013 SELECT " timestamp " , " s e n s o r i d " , " v a l u e " FROM p u b l i c . "Rawdata" WHERE " timestamp " >= ’ 2013−04−01␣ 0 0 : 0 0 : 0 0 ’ AND " timestamp " < ’ 2013−05−01␣ 0 0 : 0 0 : 0 0 ’ ; Unser Vorverarbeitungsprogramm hat dann eine Map3 aufgestellt, die jedem Zeitstempel eine Liste von Werten zuordnet, wobei die Werte gemäß ihrer Reihenfolge den Sensoren zugeordnet waren. War diese Map fertig aufgebaut, wurde für jedes SchlüsselWert-Paar der Map ein Datentupel aufgestellt und in die neue Tabelle geschrieben. Diese Prozedur wurde für jeden Monat wiederholt. Das Ganze hat etwa 200 Stunden gedauert, oder 8 Tage 8 Stunden. 3 Eine Map ist eine Datenstruktur, die Schlüsselwerten Datenwerte zuordnet. Dabei sind die Schlüsselwerte eindeutig. 16 5.3 Statistiken über die Datenbank Das große Problem mit der alten Datenbank war, dass es zeittechnisch nicht vertretbar war, darauf Datenbankanfragen durchzuführen. Das lag daran, dass für die meisten sinnvollen Abfragen die gesamte Datenbank einmal durchsucht werden musste. Bei einer Größe von über 1 Terabyte war das eine Sache von mehreren Stunden, je nach Anfrage auch im zweistelligen Bereich. Ein Grund dafür war, dass die Tabelle keinen Index hatte, also letztendlich ungeordnet war. Es war durchaus kein Problem, beliebige Daten schnell zu bekommen, diese waren allerdings mehr oder weniger zufällig gewählt. Das Problematische daran war, dass ein für uns verwertbarer Datenpunkt aus 111 Tupeln in der Datenbank bestand. Die einfachste Methode, zusammengehörige Daten aus dem Datensatz zu bekommen, war, den Zeitraum einzuschränken, wie im Listing 1 gezeigt. Um diese Daten zu erhalten, wurde jedoch die gesamte Datenbank einmal durchsucht. Aus der alten Datenbank direkt Datensätze zu generieren, stand also außer Frage. Wohlgemerkt wäre es kein Problem gewesen, eine unindizierte Datenbank vorliegen zu haben, wären die Daten bereits nach Zeitstempel gruppiert gewesen. In dem Falle hätte man einfach problemlos eine Reihe zufälliger Tupel aussuchen können. So wäre allerdings die einzige Methode gewesen, zufällige timestamp, sensorid, value-Tupel zu wählen und dann für jedes die fehlenden 110 Tupel nochmal aus der Datenbank zu suchen. Das wäre aus Zeitgründen einfach nicht tragbar gewesen. Interessanterweise war es relativ unerheblich, wie viele Daten aus der alten Tabelle gelesen werden sollten, die Anfragen haben alle etwa gleich lange gebraucht. Das lag, wie schon erwähnt, daran, dass die gesamte Tabelle für jede Anfrage durchsucht wurde. Dahingegen konnten aus der neuen Tabelle Daten in linearer Zeit abgerufen werden, siehe Abbildung 9. Dass bei einer dermaßen großen Datenbank so gute Zugriffszeiten von weniger als 20 Sekunden für alles außer extrem große Anfragen gegeben waren, war in sich schon ein gutes Resultat. Jetzt war es uns möglich, sinnvoll mit der Datenbank zu arbeiten. 6 6.1 Generierung von Datensätzen Wahl der Sensoren Um sinnvoll Daten clustern zu können, musste bedacht werden, dass der ClusteringAlgorithmus bei zu vielen Dimensionen trotz dünner Gitter sehr schnell langsamer wird. Daher war es essenziell, sich auf eine moderate Anzahl an Sensoren zu beschränken. Das beinhaltete auch, Sensoren zu wählen, deren Cluster physikalisch sinnvolle Ergebnisse liefern würde. Unsere erste Entscheidung war es daher, die Sensoren 12 und 13 – Windrichtung und Zimmertemperatur – vollständig außer Acht zu lassen. Zudem musste vermieden werden, dass Messwerte unterschiedlicher Module kombiniert werden. Unser erster generierter Datensatz sah deshalb vor, folgende Sensoren auszuwählen: 17 Abbildung 9: Zugriffszeiten auf die neue Tabelle in Abhängigkeit der Abfragegröße. Der Graph ist in doppellogarithmischer Darstellung und hat eine Steigung von 1, was ein Wachstum von O(n) beschreibt. Die neue Tabelle hat 217849954 Tupel, daher wächst die Zugriffszeit hier nicht weiter an. SensorID Gemessene Größe 11 Windgeschwindigkeit 14 15 16 17 18 19 20 Gleichspannung UDC Modul 4 Gleichstrom IDC Modul 4 Modultemperatur Modul 4 Gleichstromleistung PDC Modul 4 Gleichstromenergie EDC Modul 4 Wechselstrom-Wirkleistung PAC Modul 4 Wechselstromenergie EAC Modul 4 21 Lufttemperatur (außen) 22 Solareinstrahlung (Pyrano) Einheit m s V A ◦ C W kWh W kWh ◦ C W · m−2 Tabelle 2: Gewählte Sensoren für den ersten Datensatz. Hierbei haben wir uns auf das Solarmodul 4 beschränkt. 18 Damit konnten wir unser Clustering für das Solarmodul 4 durchführen. Bei Bedarf können hier die Sensoren für Modul 4 gegen die eines beliebigen anderen Moduls ausgewechselt werden. 6.2 Wahl der Umgebungsbedingungen Auch wichtig für die Generierung eines sinnvollen Datensatzes ist die Wahl des betrachteten Zeitintervalls. Es ist sinnvoll, nicht alle Daten auf einmal anzuschauen, sondern nach Tages- oder Jahreszeit zu filtern, je nachdem, was man aus den Daten ablesen will. Da es sich um Daten eines Solarsensors handelt, ist es relativ unsinnig, Werte in Betracht zu ziehen, bei denen es Nacht ist. Unser Testdatensatz hatte folgende Einschränkungen: Listing 2: Zeitliche Einschränkungen unseres Datensatzes date_part ( ’ hour ’ , " timestamp " ) >= 5 AND date_part ( ’ hour ’ , " timestamp " ) < 19 Das heißt, es wurden nur Daten ausgewählt, deren Zeitstempel einen Stundenwert h mit 5 ≤ h < 19 enthalten. Damit wurden die meisten Nachtdaten ausgeschlossen. Ein zweiter Datensatz hat zudem alle Werte ignoriert, die nicht in den drei (meteorologischen) Sommermonaten lagen: Listing 3: Zeitliche Einschränkungen des Sommerdatensatzes date_part ( ’ hour ’ , " timestamp " ) >= 5 AND date_part ( ’ hour ’ , " timestamp " ) < 19 AND date_part ( ’ month ’ , " timestamp " ) >= 7 AND date_part ( ’ month ’ , " timestamp " ) <= 9 Aus dem so generierten Datensatz konnten wir schon einige Trends erkennen, als wir jeweils zwei Datenreihen gegeneinander geplottet haben. Mehr dazu in Unterkapitel 6.3. 6.3 Einige Beispiele aus dem generierten Testdatensatz Aus dem Testdatensatz haben wir mit matplotlib.pyplot, einer Python-Bibliothek, Graphen aus allen Sensorpaaren erstellt. Die Graphen 10a, 10b, 11a, 11b, 12a, 12b sind 19 2d-Histogramme, das heißt, der Wertebereich wird in Quadrate unterteilt, deren Wert bestimmt wird durch die Anzahl der Datenpunkte innerhalb des Quadrats. Dieser Wert wird dann durch die Farbe des Quadrats repräsentiert. Diese Art von Graph wird dann nützlich, wenn man zu viele Datenpunkte für einen Scatter-Plot hat. Bei diesem werden einfach alle Datenpunkte eingezeichnet. Dies wird irgendwann sehr nichtssagend, wenn zu viele Punkte eng zusammen liegen. Aus einem Histogramm lässt sich jedoch die Punktdichte ablesen. Wie in Abschnitt 6.5 erklärt, wurden alle Dimensionen im Datensatz auf das Einheitsintervall [0, 1] herunterskaliert. Das Ursprungsintervall ist bei allen Graphen angegeben. Die Skalierung war linear. Abbildung 10: (l) Histogramm von Gleichspannung des Moduls 4 und Außentemperatur. Hier lassen sich einige potentielle Cluster erkennen. Die Spannung wurde vom Intervall [−1V, 245V] runterskaliert, die Außentemperatur von [−17.7◦ C, 35.6◦ C]. (r) Histogramm von Gleichstrom zu Gleichstromleistung. Die Linien entstehen durch unterschiedliche Spannungen des Moduls gemäß der physikalischen Gesetzmäßigkeit P = U. I Der Strom wurde vom Intervall [−1A, 10.4A] runterskaliert, die Leistung von [−3.6W, 1870W]. 6.4 Behandlung von null-Werten Beim Messen von Daten kann es von Zeit zu Zeit vorkommen, dass ein Sensor ausfällt. Dadurch entstehen Lücken im Datensatz. Diese müssen beim Auswerten der Daten berücksichtigt werden. Das Standardvorgehen für fehlende Daten ist es, die Datenwerte aus den umliegenden Werten zu interpolieren, mit der Argumentation, dass sich die Werte innerhalb von nur wenigen Zeitschritten nicht groß ändern werden. Das ist, vor allem bei sehr großen Datensätzen, wo kleine Fehler in der puren Menge von Daten verschwindende Relevanz haben, ein akzeptables Vorgehen, wenn mal ein oder zwei Werte fehlen. 20 Abbildung 11: (l) Histogramm der Modultemperatur gegen die Lufttemperatur. Man sieht, dass beide meist linear abhängig sind. Bei höherer Lufttemperatur ist die Modultemperatur zeitweise deutlich höher. Dies lässt sich auf Aufwärmung durch Sonneneinstrahlung zurückführen. Die Modultemperatur wurde von [−19.6◦ C, 63.1◦ C] runterskaliert, die Außentemperatur von [−17.5◦ C, 35.7◦ C] (r) Histogramm der Modultemperatur gegen die Sonneneinstrahlung. Die Modultemperatur wurde von [−19.6◦ C, 63.1◦ C] runterskaliert, die Sonneneinstrah W W lung von 0 m2 , 1405 m2 Abbildung 12: (l) Histogramm der Gleichstromenergie gegen die Lufttemperatur. Die Gleichstromenergie wurde von [0kWh, 11.3kWh] runterskaliert, die Außentemperatur von [−17.5◦ C, 35.7◦ C] (r) Histogramm der Lufttemperatur gegen die Sonneneinstrahlung. Die Modultemperatur wurde von [−17.5◦ C, 35.7◦ C] runterskaliert, die Sonneneinstrah W lung von 0 m2 , 1405 mW2 21 Im Photovoltaikdatensatz wurden durch die Verwendung von SenseTrace[WA] einzelne kurze Ausfälle bereits behoben, siehe dazu Abschnitt 6.2. Um solche mussten wir uns also nicht mehr kümmern. Übrig blieben Ausfälle in der Größenordnung von einem Tag bis zwei Monaten. Bei solchen Sensorausfällen gibt es nichts mehr, was man machen kann, um die Daten wieder herzustellen. Wir haben uns daher dafür entschieden, bei der Auswahl der Datensätze Tupel zu ignorieren, in denen mindestens eine von uns ausgewählte Datenreihe null ist. Tatsächlich lagen die null-Intervalle bei den meisten Sensoren auch auf den gleichen Zeitabschnitten, wodurch ein Großteil der Tupel vollständig vorhanden war. Die Datenbank hat im Bereich von 0.5% null-Werte. Damit ist sie noch zu sehr großen Teilen verwendbar. 6.5 Abspeichern der Datensätze Für das Clustering wurden kleinere Datensätze aus dem Gesamtdatensatz generiert, da es unrealistisch war, den vollständigen Datensatz auf einmal zu clustern. Diese Testdatensätze enthielten etwa 10 Sensoren (siehe Abschnitt 6.1) und zwischen 1000 und 20000 Tupel. Die Bibliothek Weka[Wai], die zum Arbeiten mit großen Datensätzen entwickelt wurde, hat auch ein Speicherformat für Datensätze, das ARFF4 -Format, entwickelt. Dieses hat einen Header, der eine Beschreibung des Datensatzes sowie den Typ und Namen jeder Dimension angibt. Darunter folgen die Daten im CSV5 -Format. Diese Datensätze sehen dann etwa folgendermaßen aus: Listing 4: Auszug aus einem ARFF-Datensatz @RELATION " t e s t d a t a " @ATTRIBUTE @ATTRIBUTE ... @ATTRIBUTE @ATTRIBUTE s e n s o r 1 1 NUMERIC s e n s o r 1 4 NUMERIC s e n s o r 2 1 NUMERIC s e n s o r 2 2 NUMERIC @DATA 0.203778 ,0.618368 ,... ,0.0763272 0.0359501 ,0.00126472 ,... ,0.0609097 0.0528662 ,0.647453 ,... ,0.103448 0.28476 ,0.607578 ,... ,0.130055 0.0697747 ,0.576479 ,... ,0.0538646 4 5 attribute-related file format Comma Separated Values, also Daten im Textformat, die Spalten durch Kommas getrennt. 22 0.0666449 ,0.656422 ,... ,0.0732332 ... Ein solcher Datensatz kann, da er sich im Textformat befindet, noch stark komprimiert werden. Der Vorteil daran, die Daten nicht on-the-fly beim Clustering selbst zu generieren war, dass die Datensätze nur einmal generiert werden müssen. 7 Resultate Der in den Kapiteln zuvor beschriebene Algorithmus zur Clusterbestimmung mittels Dichteschätzung und Graphverfahren wurde erfolgreich implementiert, parallelisiert und getestet. Zum Testen haben wir eine eigene Testumgebung in Python geschrieben, welche es sehr leicht macht Musterdatensätze zu generieren bzw zu laden und darauf den Algorithmus zur Clusterbestimmung anzuwenden. Diese Testumgebung erlaubt es auch einfach mithilfe einer grafischen Oberfläche die Plattform und das Gerät auf dem die OpenCL Kernel der Subalgorithmen ausgeführt werden zu ändern. Die Graphen 7 8 1 wurden zum Beispiel mit dieser Testumgebung erzeugt! Als Musterdatensätze können in dieser Testumgebung Datensätze wie die Halbmonde ersteller werden, aber zweidimensionale Datensätze bei denen sich die Datenpunkte um eine beliebige Anzahl Cluster herum konzentrieren. Auch die Auswertung von höherdimensionalen Datensätzen funktioniert! Wir haben Die Auswertung erfolgte auf der Xeon Phi mit einem Datensatz von 100 000 Datenpunkten bei 10 Dimensionen. Die Laufzeiten der einzelnen Schritte sind in den folgenden Graphen eingetragen. 1.138s Rechte Seite der Gleichung berechnen 0.046s LGS berechnen (CG Vefahren) 181.6s Kanten Entfernen + Cluster suchen 21.53s Graph erstellen (k nearest neighbors Mit einer Parameterbelegung von k = 6 erhalten wir 731 Cluster. Allerdings befinden sich beinahe alle Datenpunkte in einem einzigen Cluster (37447 Datenpunkte) oder in überhaupt keinem Cluster da ihre Dichte zu gering ist (61425 Datenpunkte). Die restlichen Cluster haben sehr wenige Datenpunkte (meist nur zwischen 1 und 30). Die Datenpunkte dieses Datensatzes scheinen sich also hauptsächlich um ein Cluster herum zu verteilen, allerdings mit einer recht geringen Dichte wie man an der Anzahl der Datenpunkte sieht die es in kein Cluster geschaft haben. Die kleineren Cluster sind 23 dann durch kleinere Anhäufungen an Datenpunkten zu erklären die um das eigentliche Cluster herum auftreten und es über die Dichteschwelle schaffen. 8 Fazit Die Implementierung und die Vorverarbeitung der Daten waren erfolgreich. Wir haben zwar nicht mehr den kompletten Datensatz geclustert, jedoch die Vorraussetzungen dafür geschaffen. Der Algorithmus wurde auf der Zielarchitektur ausgeführt und hat zufriedenstellende Ergebnisse geliefert. Die Datenbank ist nun in einem Zustand, mit dem man sehr gut arbeiten kann. 24 Literatur Adl13. Bis06. Adler, Hendrik: Langzeitüberwachung von Photovoltaikanlagen. 2013. – Diplomarbeit Bishop, Christopher M.: Pattern Recognition and Machine Learning (Information Science and Statistics). Secaucus, NJ, USA : Springer-Verlag New York, Inc., 2006. – ISBN 0387310738 Gro. Group, The PostgreSQL Global D.: PostgreSQL 8.4.22 Documentation. http://www.postgresql. org/docs/8.4/static/ McK12. McKinney, W.: Python for Data Analysis: Data Wrangling with Pandas, NumPy, and IPython. O’Reilly Media http://books.google.de/books?id=v3n4_AK8vu0C. – ISBN 9781449323615 Pfl10. Pflüger, Dirk: Spatially Adaptive Sparse Grids for High-Dimensional Problems. Verlag Dr. Hut http://www5.in.tum.de/pub/pflueger10spatially.pdf. – ISBN 9783868535556 PPB12. Peherstorfer, Benjamin ; Pflüger, Dirk ; Bungartz, Hans-Joachim: Clustering Based on Density Estimation with Sparse Grids. In: KI 2012: Advances in Artificial Intelligence Bd. 7526, Springer, Oktober 2012 Ver. Vermeulen, Jeroen T.: libpqxx C++ PostgreSQL Online Documentation. pqxx.org/devprojects/ libpqxx/doc/2.6.4/html/Reference/ WA. Wurster, Thomas ; Adler, Hendrik: SenseTrace. Internes Dokument, . – Handbuch Wai. Waikato, Machine Learning G. o.: Weka Online Documentation. http://www.cs.waikato.ac.nz/ ml/weka/documentation.html Zin10. Zinßer, Bastian: Jahresenergieerträge unterschiedlicher Photovoltaik-Technologien bei verschiedenen klimatischen Bedingungen. 2010 25