Dienstag, 13. September 2016

Trendumkehr in Datenreihen

Es muss auch kurze Blogposts geben! Nach meinem jüngsten Blog-Exzess über Dominanzrelationen folgt hier ein Beitrag über ein vor längerer Zeit von Gerhard Lukert ersonnenes Verfahren, um Trendumkehrpunkte in Datenreihen zu ermitteln.

Es gibt Zahlenfolgen, die, wie die Börsenkurse an aufeinanderfolgenden Börsentagen, durch ihre Irregularität gekennzeichnet sind. Und zugleich kann man nach zugrundeliegenden Mustern, nach Trends, nach irgendwie um diese Irregularität "bereinigten" Informationen fragen. Im Fall der Börsenkurse leben Analysten davon, mit irgendwelchen Hilfslinien oder numerischen Verfahren aus den Daten Trends vorherzusagen.

Gerhard Lukerts Fragestellung war eine astrologische: ihn interessierten Tage, die besonders von einer Wende im Wachstumsverhalten geprägt sind. Solche Tage nennt er Umkehrtage. Ein Verfahren, um solche Umkehrtage, die besonders markant die Trendwende in sich tragen, könnte nach Gerhard Lukert

eine nützliche Sache sein, weil die Umkehrtage signifikant "andere" Konstellationentypen haben müssten als die Trendfolgetage; es sind die eigentlich kritischen bzw. impulsgebenden Tage.

Sein Verfahren besteht darin, aus der ursprünglichen Zahlenfolge zwei neue Zahlenfolgen zu ermitteln – die Folgen der oberen und der unteren Umkehrpunkte, in denen sich jeweils der Trend umkehrt: bei den oberen Umkehrpunkten hören die Zahlen auf zu wachsen, bei den unteren Umkehrpunkten hören sie auf zu fallen.

Auf diese beiden Teilfolgen der oberen und unteren Umkehrpunkte kann dasselbe Verfahren jeweils noch einmal angewendet werden. Dabei ergeben sich vier neue Teilfolgen. Da ihn nur ein besonders reiner Ausdruck der Trendumkehr interessiert, behält er nur die oberen Umkehrpunkte der oberen und die unteren Umkehrpunkte der unteren Umkehrpunkte der vorherigen Iteration, so dass er auch in diesem Schritt mit zwei Teilfolgen verbleibt. Bei jedem Schritt dünnen sich die Folgen weiter aus, und die verbleibenden Punkte enthalten gewissermaßen besonders viel Essenz der Trendumkehr. Man verbleibt mit sehr wenigen Daten- (und damit in der Regel Zeit-)Punkten, die die Qualität dieser Umkehr besonders gut ausdrücken.

Auf der Webseite http://ruediger-plantiko.net/filter/ kann man das Verfahren ausprobieren. Die Eingabe der Zahlenreihe kann aus einer Textdatei erfolgen oder über die Zwischenablage in das Eingabefeld. Mit dem Doppelkreuz # werden Kommentare eingeleitet, die beim Einlesen ignoriert werden, sie können auch am Ende einer Zeile stehen. Auch Leerzeilen werden ignoriert. Am Beginn der Zeile muss eine Zahl stehen, die von JavaScript als Zahl erkannt werden kann. Danach kann, von Leerzeichen oder einem Semikolon getrennt, ein Bezeichner folgen, der dann auch im Graphen angezeigt wird. Enthalten die Zeilen nur eine Zahl, so wird die Zeilennummer als Bezeichner verwendet.

Mit Daten auswerten, oder den Buttons ◀ und ▶ zum Fortsetzen der Iterationen, können die Umkehrpunkte ermittelt und in einem Graphen zusammen mit der ursprünglichen Zahlenfolge angezeigt werden.

Die Anzeige des Graphen erfolgt mit c3.js, einem auf Graphen spezialisierten Zusatz zu der bekannten Bibliothek d3.js für Data Driven Documents.

Hier ein Screenshot nach Datenauswertung:

Hier noch ein paar Bemerkungen zur Implementierung (in filter.js). Beim Parsen der Eingabe werden zunächst die Kommentare und Leerzeilen entfernt. Danach wird aus jeder Datenzeile eine Zahl und ein nachfolgender Bezeichner eingelesen. Zusammen mit dem zur eindeutigen Benennung vorangestellten Index wird so ein Array von Arrays (AoA) erzeugt, wobei jedem Folgenelement ein vierelementiges Array zugeordnet ist: das erste Element erhält den Index, das zweite Element den Zahlenwert, das dritte den Bezeichner und das vierte das Wachstumsverhalten als Signum (also mit den Werten -1, 0 oder 1) im Vergleich zum Vorgänger.

  function parseInput( stream ) {
    const DATA_PATTERN = /^([-+.eE\d]+)(?:\s*|;)(.*)/;
    const COMMENT_PATTERN = /\s*#.*$/;
    var series = [];
    stream.split('\n').forEach( function(line,i) {
      try {
        line = line.replace(COMMENT_PATTERN,"");
        if (!line.match(/\S/)) return; // Leerzeilen überspringen
        var pair = parseLine(line,i);
        series.push( [ series.length, pair[0], pair[1] ] );
      } catch (e) {
        e.message += " (Zeile "+(i+1)+": '"+line+"')";
        throw e;
      }
    });
    return series.map( appendGrowth );

    function parseLine( line, i ) {
      var m = line.match(DATA_PATTERN);
      if (m === null || m.length === 0) {
        throw new Error("Zeile muss mit einer Zahl beginnen");
      }
      checkNumeric( m[1] );
      return [ 1*m[1], m[2] || '#'+(i+1) ]
    }

  }
Hier ermittelt appendGrowth das Signum der Datenänderung im Vergleich zum Vorgänger. Stimmt der Wert des Vorgängers mit dem aktuellen Wert überein, wird weiter zurückgeschaut, bis man einen echt größeren oder echt kleineren Wert gefunden hat. Die Funktionsschnittstelle entspricht dabei der Schnittstelle von Array-Iteratorfunktionen wie map, so dass dieses Signum mit dem Aufruf .map( appendGrowth ) den (vorher nur dreielementigen) Daten-Arrays hinzugefügt werden kann.
  function appendGrowth(data,i,total) {

    return data.concat( getGrowth( ) );

// Der Wert ist immer -1, 0 oder +1 
    function getGrowth( ) {
      var sign = 0;
// Zurückspulen, bis ein echtes Zu- oder Abnehmen gefunden wurde
      for (let j=i-1;j>=0&&sign===0;j--) {
        sign = Math.sign( data[1] - total[j][1] );
      }
      return sign;
    }
  }
Die zentrale Funktion getTurningPoints ermittelt aus einer Zahlenfolge series die Folge ihrer Umkehrpunkte, und zwar je nach Funktion condition die Folge der oberen oder der unteren Umkehrpunkte. Hierzu wird, wie man vom Namen erwarten könnte, die JavaScript-Funktion Array.prototype.filter verwendet. Die Elemente der entstehenden Teilmenge, die ja vierelementige Arrays sind, werden dann kopiert, da das vierte Element, das Wachstumsverhalten, für jede Reihe neu ermittelt werden muss.
  function getTurningPoints(series,condition) {

    var newSeries = 
          series
            .filter( conditionSatisfied )
            .map( copy )
            .map( appendGrowth );
    return newSeries;

// Auf Umkehrpunkt prüfen (bis zum vorletzten Datenpunkt möglich)
    function conditionSatisfied(data, i) {
      return (i < series.length - 1) &&
             condition(data[3],series[i+1][3])
    }

// Kopie der ersten drei Elemente
    function copy(data) {
      return data.slice(0,3);  
    }

  }
Aus dieser Funktion resultieren die Funktionen getUpperTurningPoints und getLowerTurningPoints, die sich nur durch die verwendete condition beim Aufruf von getTurningPoints unterscheiden:
  function getUpperTurningPoints(series) {
    return getTurningPoints( series, stopsIncreasing );
  }

  function getLowerTurningPoints(series) {
    return getTurningPoints( series, stopsDecreasing );
  }

  function stopsIncreasing(currentGrowth,nextGrowth) {
    return (currentGrowth>0) && (nextGrowth<0);
  }

  function stopsDecreasing(currentGrowth,nextGrowth) {
    return (currentGrowth<0) && (nextGrowth>0);
  }

Keine Kommentare :