Deluminator - Zaubertechnik in der Muggel Welt

Posted on Mi 30 Dezember 2020 in Fun with Machine Learning

Wer die Harry Potter Bücher oder wenigstens die Film kennt, hat schon mal was vom Deluminator gehört, einem verzauberten Feuerzeug, das alle nahe gelegenen Lampen aus- oder anschalten kann. Nachdem wir kürzlich wieder einen Film aus der Serie gesehen haben, kam die frage auf, ob man so ein Feuerzeug auch für Muggel (also normale, "nicht-magische" Menschen) erstellen kann. In meinem Smarthome, bei dem die einzelnen Lampen über ein Web-Interface geschaltet werden können, sollte das meiner Meinung nach schon möglich sein. Deshalb habe ich mich während der Weihnachtsferien hingesetzt und das mithilfe von Maschinellem Lernen und einem Raspberry PI umgesetzt. Wie genau? Das erkläre ich in diesem Beitrag.

Was mein Deluminator also können sollte ist: das Licht in dem Raum, in dem es sich befindet, an- bzw. auszuschalten. Er ist also sozusagen ein Lichtschalter, der weiß, wo er ist. Das Licht an- und auszuschalten ist bei mir zum Glück kein Problem, da ich bei mir die Möglichkeit habe, Lampen über ein Web-Interface zu schalten - Smarthome sei Dank! Das eigentliche Problem bei mir ist also, woher der Deluminator wissen soll, wo er sich gerade befindet.

Der einfachste Weg wäre, den Deluminator als einfache Infrarot-Fernbedienung umzusetzen: Man müsste in jedem Raum nur einen Infrarot-Empfänger installieren, der dann das passende Licht schaltet. Praktischerweise gibt es schon den dazu passenden "Original Harry Potter Zauberstab" mit eingebauter Infrarot-Fernbedienung. Für mich war diese Lösung keine Option, weil ich nicht extra in jedem Zimmer zusätzliche Hardware installieren möchte, nur um das Licht zu schalten - wie gesagt es sollte ein kleines Ferienprojekt sein und kein größeres Projekt.

Eine Alternative wäre die Verwendung von Bluetooth Beacons: Das sind kleine Bluetooth-Leuchttürme, die in der Wohnung verteilt werden müssten und anhand derer der Deluminator erkennen könnte, wo er ungefähr ist. Wie beim vorherigen Beispiel viel zu viel Aufwand, außerdem will ich das 2.45 GHz Band nicht noch mit zusätzlichen Sendern belasten.

Aber mit einem anderen Sender in diesem Band möchte ich das Problem lösen, nämlich mithilfe der verschiedenen W-LAN Basisstationen in meiner Umgebung. W-LAN wird schon seit längerem für grobe (~200 m) Navigation genutzt, wer aber schon mal in den verschiedenen Räumen seiner Wohnung das W-LAN Netz gescannt hat, wird schon gesehen haben, dass man bestimme W-LAN-Netze nur in bestimmten Räumen / Hausseiten sieht; auch ändert sich die Signalqualität deutlich, wenn sie durch eine oder mehrere Mauer muss. Meine Hoffnung ist, dass ich mithilfe eines Machine Learning Modells in der Lage bin, anhand der W-LAN-Situation zu bestimmen, in welchem Raum ich mich gerade befinde.

Hardware-Aufbau

Dank der aktuellen Quasi-Lockdown-Situation habe ich leider keine große Auswahl, was die Hardware für dieses Projekt angeht und muss nehmen was gerade da ist: Als Kopf für das Projekt nutze ich einen Raspberry Pi 3 B, den ich an einer 67Wh Powerbank betreibe. Ein Rasberry Pi mit dual-band W-LAN wäre vielleicht besser geeignet, stand aber leider nicht zur Verfügung. Als Schalter nutze ich einen Aufputz-Doppel-Taster, den ich noch von meinen Smarthome-Tests übrig hatte. Den Taster schließe ich auf der einen Seite an Pin 11 (GPIO 17) des Raspberry Pi an und die andere Seite an Pin 39 der Masse.

Hardware setup of the Deluminator

Hardware-Aufbau des Deluminators, ein bisschen größer als das Orginal, dafür auch von Muggeln zu verwenden.

Software

Nachdem die Hardware geklärt ist, kommen wir zur Software. Als erstes muss ich den Taster einbinden. Dafür nutze ich die Python RPi Bibliotheken: Mithilfe der GPIO Klasse lege ich GPIO 17 a.k.a. pin 11 als Eingang fest und aktiviere den Pullup-Widerstand. Mithilfe der GPIO.input kann ich jetzt den aktuellen Status abfragen, der Null wird wann immer der Taster gedrückt wird. Für das weitere Projekt habe ich mir die Funktion wait_for_key() geschrieben, die die Ausführung des Programmes so lange anhält, bis der Taster gedrückt wird:

In [1]:
import RPi.GPIO as GPIO
PUSHBUTTON = 17
GPIO.setmode(GPIO.BCM)
GPIO.setup(PUSHBUTTON, GPIO.IN, pull_up_down=GPIO.PUD_UP)

def wait_for_key():
    last_val = 1
    while True:
        val = GPIO.input(PUSHBUTTON)
        if val != last_val and val == 0:
            return
        last_val = val

W-LAN-Netz scannen

Als nächstes musste ich eine Möglichkeit finden, einen W-LAN-Scan durchzuführen. Da ich kein passendes Python-Modul dafür finden konnte, habe ich mir einen kleinen Parser für die Ausgabe des Tools iwlist geschrieben. Als root Benutzer kann man mit dem Kommando iwlist scan einen W-LAN-Scan anstoßen und die Ergebnisse auslesen. Als normaler User bekommt man im Glücksfall nur das Ergebnis der letzten Messung oder oft nur den Status der aktuellen Verbindung zu sehen.

Von den Daten, die iwlist zur Verfügung stellt, brauche ich eigentlich nur die BSSID, die MAC-Adresse der Basisstation und einen Wert für die Signalqualität: Hier gibt es zwei Werte, einmal den Wert "Quality", der zwischen 0 und 70 rangiert, und zum anderen den Signalpegel in dBm. Beide Werte sollten, so weit ich weiß, eigentlich verbunden sein und damit dasselbe anzeigen - nichtsdestotrotz speichere ich beide, um auf der sicheren Seite zu sein.

Wie zuvor packe ich alles Nötige in eine Funktion, die mir ein Array zurückgibt, in dem jeder Eintrag wieder ein Array ist, mit den Informationen von jeweils einer W-LAN-Basisstation:

In [2]:
import os

def do_scan():
    data = []
    bssid = None
    pipe = os.popen("sudo iwlist wlan0 scan", "r")
    for line in pipe:
        if line.strip().startswith("Cell"):
            if bssid is not None:
                data.append([bssid, sig, signal_level])
            arr = line.strip().split()
            bssid = arr[-1]
        if line.strip().startswith("Quality="):
            arr = line.strip().split()
            sig = arr[0].split("=")[1].split("/")[0]
            signal_level = arr[2].split("=")[1]
    pipe.close()

    if bssid is not None:
        data.append([bssid, sig, signal_level])
    return data

do_scan()
Out[2]:
[['04:00:00:00:00:33', '56', '-54'],
 ['05:00:00:00:00:89', '28', '-82'],
 ['02:00:00:00:00:29', '21', '-89'],
 ['00:00:00:00:00:35', '17', '-93'],
 ['01:00:00:00:00:D4', '20', '-90']]

Aus Datenschutz Gründen habe ich u.a. alle MAC Adressen und andere möglichen privaten Informationen zensiert.

Bevor ich jetzt zu viel Zeit in das Projekt rein stecke, möchte ich erst mal sehen ob das Ganze überhaupt so funktionieren kann. Dafür sammle ich nur ein paar Datenpunkte pro Raum und trainiere dann ein einfaches Modell zum Testen. Das sollte mir hoffentlich helfen, ein Gefühl dafür zu bekommen, wie gut das Ganze umsetzbar ist und wo ich vielleicht meine Strategie ändern muss.

Zuerst brauche ich ein paar Daten. Die erhebe ich mit dem folgenden Programm, das bei Knopfdruck nach W-LAN scannt und die Ergebnisse dann in eine Datei speichert. Dies mache in jedem Raum 15-mal, wobei ich zwischen den einzelnen Scans meine Position innerhalb des Raumes ändere.

In [6]:
scans_per_room = 15 # Anzahl von Scans per Raum
roomID = 1 # Aktuelle Raum nummer

for loop in range(scans_per_room):
    wait_for_key() # Warte auf Knopfdruck
    data = do_scan() # Scanne nach WLANS
    # Speichere das Ergbniss ab
    with open("Data-{}-{}.txt".format(roomID, loop),"w") as f:
        for row in data:
            f.write(" ".join(row)+"\n")
    print("Scan {} done".format(loop))
Scan 0 done
Scan 1 done
Scan 2 done
Scan 3 done
Scan 4 done
Scan 5 done
Scan 6 done
Scan 7 done
Scan 8 done
Scan 9 done
Scan 10 done
Scan 11 done
Scan 12 done
Scan 13 done
Scan 14 done

Ich scanne so insgesamt vier Räume und habe damit insgesamt 60 Datenpunkt für das Training. Bevor ich jetzt zum nächsten Schritt komme, möchte ich noch den Zusammenhang zwischen Signalqualität und Signalpegel von iwlist untersuchen. Dafür schreibe ich alle Datenpunkte in eine Datei:

In [7]:
 !cat Data-*.txt > Combined.txt

Und dann plotte ich mithilfe von Matplotlib das Verhältnis zwischen den beiden Werten:

In [8]:
import numpy as np
import matplotlib.pyplot as plt

data = np.loadtxt("Combined.txt",usecols=(1,2))

plt.xlabel("Quality")
plt.ylabel("Signal level [dBm]")
plt.plot(data[:,0],data[:,1],"+")
plt.title("Wifi Quality vs. signal level")
plt.show()

Wie man gut sehen kann, skalieren die beiden linear, nur für Signalpegel größer -40 dBm bleibt der Wert für die Quality fix bei 70. Für den weiteren Verlauf werde ich den Quality Wert vernachlässigen, da hier eher weniger Information drinsteckt als in den Werten des Signalpegels. Da ich den Wert später noch brauchen werde, bestimme ich noch den Signalpegel für den Fall von einer Quailty von Null, der bei -110 dBm liegt.

Für das Training müssen wir die Daten in eine andere Form bringen, dafür muss ich aber erstmals eine Liste aller gesehenen Basisstationen erstellen:

In [9]:
BSSIDS = [] # Diese Liste enthält alle BSSIDs die gesehen wurden
listdir = os.listdir(".") # Liste alle Dateien im Verzeichniss

for filename in listdir:
    if not filename.startswith("Data-"): 
        continue # Ingoriere alle nicht Dateien die keine Daten enthalten
    with open(filename, "r") as f:
        for line in f:
            bssid = line.strip().split()[0] # Extrahiere die BSSID von jeder Basisstation
            if bssid not in BSSIDS:
                BSSIDS.append(bssid)
                
BSSIDS.sort() # Sortier die Liste
    
# und speichere sie ab
with open("BSSIDS.save", "w") as f:
    f.write("\n".join(BSSIDS))
    
for i,bssid in enumerate(BSSIDS):
    print(i, bssid)
    
# Auskommentieren, wenn man an einem späteren Zeitpunkt 
# die Daten laden möchte
#with open("BSSIDS.save", "r") as f:
#    BSSIDS = f.read().split("\n")
0 00:00:00:00:00:35
1 01:00:00:00:00:D4
2 02:00:00:00:00:29
3 03:00:00:00:00:C5
4 04:00:00:00:00:33
5 05:00:00:00:00:89

In meinen Daten habe ich insgesamt sechs verschiedene Basisstationen gefunden; ob das reicht, wird sich zeigen. Als nächstes forme ich Daten so um, dass ich sie direkt fürs Trainieren nutzen kann. Jeder Datenpunkt besteht aus sechs Werten, jeweils der gemessenen Signalpegel von den sechs Basisstationen. Sollte ein Wert fehlen, weil die Basisstation nicht gefunden wurde, setze ich ihn gleich -110 dBm, dem Wert für eine Signal Qualität von Null. Wie zuvor schreibe ich gleich alles in eine Datei, damit ich die Daten später nicht nochmal erstellen muss:

In [10]:
listdir = os.listdir(".")
listdir.sort()

with open("Export.txt","w") as o:

    for filename in listdir:
        if not filename.startswith("Data"): continue

        data_dBm = {}
        f = open(filename,"r")
        for line in f:
            arr = line.strip().split()
            data_dBm[arr[0]] = arr[2]
        f.close()

        roomid = filename.split("-")[1]

        o.write("{} ".format(roomid))

        for bssid in BSSIDS:
            try:
                o.write("{} ".format(data_dBm[bssid]))
            except KeyError:
                o.write("-110 ")

        o.write("\n")

# Daten laden um zu checken das sie auch richtig gesperichert wurden
Data = np.loadtxt("Export.txt")

X = Data[:,1:] # Daten
y = Data[:,0]  # Zielwerte

print("shape of X:{}".format(X.shape))
print("shape of y:{}".format(y.shape))
shape of X:(60, 6)
shape of y:(60,)

Wie schon angekündigt nutze ich für die ersten Test ein einfaches Modell, nämlich einen Entscheidungsbaum, da diese mit wenigen Daten auskommen können und ein sehr einfach zu verstehendes Ergebnis liefern:

In [11]:
from sklearn.tree import DecisionTreeClassifier, plot_tree

# Erstellen und Trainieren eines Entscheidungsbaum
tree = DecisionTreeClassifier()
tree.fit(X,y)

# Und eine Graphen des Baumes erstellen
plt.figure(figsize=(16,9),dpi=300)
ret = plot_tree(tree, feature_names=BSSIDS, class_names=["Room 1", "Room 2", "Room 3","Room 4",], filled=True, proportion=True) 

Der erste Blick lässt erahnen das Raum 1 relative einfach zu bestimmen sein wird, schließlich ist dort auch die eine Basisstation untergebracht, deren Signal Pegel den unterschied macht. Bei Raum 2, dem Flur, sieht es schon ein bisschen schwieriger aus und Raum 3 und 4 zu unterscheiden scheint es wohl am kompliziertesten zu sein. Auch fällt auf das die zweite Basisstation (01:00:00:00:00:D4) überhaupt keine Einfluss auf das Ergebnis hat.

Aber genug Analysiert, ich packe das ganze in ein Programm, das nach dem Tastendruck, erst nach den vorhanden WLAN scant und dann bestimmt wo es ist, um dann das passende Licht zu schalten:

In [12]:
import requests

# Variante von do_scan() welche die Daten gleich Passend in Form Bringt
def do_scan2():
    data = {}
    bssid = None
    p = os.popen("sudo iwlist wlan0 scan", "r")
    for line in p:
        if line.strip().startswith("Cell"):
            if bssid != None:
                data[bssid] = float(signal_level)
            arr = line.strip().split()
            bssid = arr[-1]
        if line.strip().startswith("Quality="):
            arr = line.strip().split()
            signal_level = arr[2].split("=")[1]
    p.close()

    if bssid != None:
        data[bssid] = float(signal_level)

    out_data = []
    for bssid in BSSIDS:
        try:
            out_data.append(data[bssid])
        except KeyError:
            out_data.append(-110)
    return [out_data]

# Function die über das Web-Interface die Lampen Schaltet
def do_switch(roomID):
    url_names = [None,"Room1","Room2","Room3","Room4"]

    r = requests.get('http://lights.lan/{}/toggle'.format(url_names[roomID]))

for loop in range(20):
    wait_for_key()     # Auf den Tastendruck warten
    scanres = np.array(do_scan2()) # Scan durchführen 
    RoomID = tree.predict(scanres) # Raum bestimmen
    do_switch(int(RoomID)) # Licht Schalten
    print("Found Room {}".format(RoomID))
Found Room 1
Found Room 1
Found Room 1
Found Room 2
Found Room 2
Found Room 2
Found Room 4
Found Room 3
Found Room 3
Found Room 3
Found Room 4
Found Room 4
Found Room 3
Found Room 3
Found Room 4
Found Room 4
Found Room 2
Found Room 2
Found Room 1
Found Room 1

Was man hier leider nicht so richtig sehen kann, ist das selbst diese einfache Modell schon relative gut funktioniert hat: Raum 1 und 2 wurden sicher erkannt, bei Raum 3 hat das Modell ein paar mal Raum 4 gefunden und auch im Raum 4 war er sich nicht ganz so sicher ob es nicht doch Raum 3 oder 2 ist. Auf jeden Fall ist noch Luft nach oben aber, schon mal ein sehr interessantes Ergebnis.

Das ganze funktioniert prinzipiell also schon mal, das ist wunderbar. Ich könnte hier aufhören weil alles was ich wollte war zu sehen ob man einen Deluminator als Muggel bauen kann und das geht ja schon. Nichtsdestotrotz hab ich noch ein bisschen Zeit übrig um zu schauen ob man die Problem, die das einfache Modell noch hat, nicht doch noch irgendwie lösen kann. Dazu möchte zweit Sachen machen, einmal mehr Daten Sammeln und zum anderen noch andere Modelle des Maschinellen Lernens auszuprobieren.

Im Gegensatz zur ersten Datensammlung, habe ich keine Lust mehr durch die einzelnen Räume zu laufen und die Taste an verschiedenen Punkten zu drücken. Stattdessen stelle ich meinen Deluminator zentral in den einzelen Räumen auf und lasse ihn in zufälligen Abständen 85 Messungen durchführen, so das ich insgesamt 100 Messung pro Raum habe:

In [13]:
import random
import time

roomID = 1 # Aktuelle Raum nummer

for loop in range(15,101):
    data = do_scan() # Scanne nach WLANS
    # Speichere das Ergbniss ab
    with open("Data-{}-{}.txt".format(roomID, loop),"w") as f:
        for row in data:
            f.write(" ".join(row)+"\n")
    print("Scan {} done".format(loop))
    
    # Warte im durschnit 15 sekunden zwischen jeder messung aber nicht weniger als 3s
    time_to_wait = max(3, random.normalvariate(15,7))
    time.sleep(time_to_wait)
Scan 15 done
Scan 16 done
Scan 17 done
Scan 18 done
Scan 19 done
Scan 20 done
Scan 21 done
Scan 22 done
Scan 23 done
Scan 24 done
Scan 25 done
Scan 26 done
Scan 27 done
Scan 28 done
Scan 29 done
Scan 30 done
Scan 31 done
Scan 32 done
Scan 33 done
Scan 34 done
Scan 35 done
Scan 36 done
Scan 37 done
Scan 38 done
Scan 39 done
Scan 40 done
Scan 41 done
Scan 42 done
Scan 43 done
Scan 44 done
Scan 45 done
Scan 46 done
Scan 47 done
Scan 48 done
Scan 49 done
Scan 50 done
Scan 51 done
Scan 52 done
Scan 53 done
Scan 54 done
Scan 55 done
Scan 56 done
Scan 57 done
Scan 58 done
Scan 59 done
Scan 60 done
Scan 61 done
Scan 62 done
Scan 63 done
Scan 64 done
Scan 65 done
Scan 66 done
Scan 67 done
Scan 68 done
Scan 69 done
Scan 70 done
Scan 71 done
Scan 72 done
Scan 73 done
Scan 74 done
Scan 75 done
Scan 76 done
Scan 77 done
Scan 78 done
Scan 79 done
Scan 80 done
Scan 81 done
Scan 82 done
Scan 83 done
Scan 84 done
Scan 85 done
Scan 86 done
Scan 87 done
Scan 88 done
Scan 89 done
Scan 90 done
Scan 91 done
Scan 92 done
Scan 93 done
Scan 94 done
Scan 95 done
Scan 96 done
Scan 97 done
Scan 98 done
Scan 99 done
Scan 100 done

Nach ungefähr zwei Stunden habe ich insgesamt 400 Datensätze bekommen, die wie zuvor umgewandelt werden um dann die Analyse zu starten:

In [14]:
listdir = os.listdir(".")
listdir.sort()

with open("Export2.txt","w") as o:

    for filename in listdir:
        if not filename.startswith("Data"): continue

        data_dBm = {}
        f = open(filename,"r")
        for line in f:
            arr = line.strip().split()
            data_dBm[arr[0]] = arr[2]
        f.close()

        roomid = filename.split("-")[1]

        o.write("{} ".format(roomid))

        for bssid in BSSIDS:
            try:
                o.write("{} ".format(data_dBm[bssid]))
            except KeyError:
                o.write("-110 ")

        o.write("\n")

Data = np.loadtxt("Export2.txt")

X = Data[:,1:] # Daten
y = Data[:,0]  # Zielwerte

print("shape of X:{}".format(X.shape))
print("shape of y:{}".format(y.shape))
shape of X:(400, 6)
shape of y:(400,)

Um die nun folgenden Tests mit den verschiedenen Modellen des Maschinellen Lernens besser einordnen zu können, teile ich den Datensatz in einen Trainings- und einen Test Datensatz. Erstere wird fürs trainieren der Daten genutzt und letztere um zu überprüfen wie gut das Modell wirklich ist wenn man es mit neuen unbekannten Daten füttert.

In [15]:
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1, stratify=y)

print("shape of X_train:{} and of y_train {}".format(X_train.shape,y_train.shape))
print("shape of X_test:{} and of y_test {}".format(X_test.shape,y_test.shape))
shape of X_train:(320, 6) and of y_train (320,)
shape of X_test:(80, 6) and of y_test (80,)

Für den einfachen Test am Anfang, habe ich eine Algorithmus nach Gefühl ausgewählt, diesmal möchte ich herausfinden welcher Algorithmus der beste für mein Projekt ist. Dafür probiere ich der Reihe nach alle sinnvollen Klassifizierungsalgorithmen die mir scikit-learn zur Verfügung stellt aus und nehme mir dann die besten drei um zu schauen, ob man diese noch weiter verbessern kann. Ich gehe jetzt nicht auf jeden einzelne Algorithmus ein, dafür gibt es auf (scikit-learn Homepage)[https://scikit-learn.org/stable/modules/classes.html] eine ausführliche Beschreibungen mit vielen Beispielen. Da einige Algorithmen für initiale Werte zufällig Zahlen brauchen und diese oft auch das Ergebnis beeinflussen können, führe ich jeden Algorithmus zehnmal mit unterschiedlichem initialen zustand des Zufallsgenerators aus, um mögliche Beeinträchtigung dadurch entgegenzuwirken:

In [16]:
from sklearn.linear_model import PassiveAggressiveClassifier, RidgeClassifier
from sklearn.linear_model import RidgeClassifierCV, SGDClassifier
from sklearn.gaussian_process import GaussianProcessClassifier
from sklearn.neighbors import KNeighborsClassifier, RadiusNeighborsClassifier, NearestCentroid
from sklearn.svm import LinearSVC, NuSVC, SVC
from sklearn.tree import DecisionTreeClassifier, ExtraTreeClassifier
from sklearn.ensemble import AdaBoostClassifier, ExtraTreesClassifier
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

models = [PassiveAggressiveClassifier(), 
          RidgeClassifier(),
          RidgeClassifierCV(),
          make_pipeline(StandardScaler(), SGDClassifier()),
          GaussianProcessClassifier(),
          KNeighborsClassifier(),
          RadiusNeighborsClassifier(radius=60),
          NearestCentroid(),
          LinearSVC(max_iter=10000),
          make_pipeline(StandardScaler(), NuSVC()),
          make_pipeline(StandardScaler(), SVC()),
          DecisionTreeClassifier(),
          ExtraTreeClassifier(),
          AdaBoostClassifier(),
          ExtraTreesClassifier(),
          GradientBoostingClassifier(),
          RandomForestClassifier(),
          make_pipeline(StandardScaler(), MLPClassifier(max_iter=10000)),
          make_pipeline(StandardScaler(), MLPClassifier((100,100),max_iter=1000)),
          make_pipeline(StandardScaler(), MLPClassifier((100,100,100),max_iter=1000)),
          make_pipeline(StandardScaler(), MLPClassifier((100,100,100,100),max_iter=1000))
         ]

results = []
for model in models:
    try:
        name = model._final_estimator.__str__()
    except AttributeError:
        name = model.__str__()
    accmax = 0
    best_rnd = None
    for rnd in [5,123,42,1337,321,111,555,24,777,888]:
        try:
            model._final_estimator.random_state = rnd
        except AttributeError:
            model.random_state = rnd
        model.fit(X_train, y_train)
        acc = model.score(X_test, y_test)
        if acc > accmax:
            accmax = acc
            best_rnd = rnd
    results.append([accmax, name, best_rnd])
print("Results: ")
for position,(accmax, name, rnd) in enumerate(sorted(results, reverse=True)):
    print ("{:2}) {} (Acc: {:2}%) (RND:{})".format(position+1, name, accmax*100., rnd))
Results: 
 1) RandomForestClassifier() (Acc: 92.5%) (RND:1337)
 2) MLPClassifier(max_iter=10000) (Acc: 92.5%) (RND:5)
 3) NearestCentroid() (Acc: 91.25%) (RND:5)
 4) SVC() (Acc: 90.0%) (RND:5)
 5) SGDClassifier() (Acc: 90.0%) (RND:5)
 6) RidgeClassifierCV(alphas=array([ 0.1,  1. , 10. ])) (Acc: 90.0%) (RND:5)
 7) RidgeClassifier() (Acc: 90.0%) (RND:5)
 8) PassiveAggressiveClassifier() (Acc: 90.0%) (RND:321)
 9) ExtraTreesClassifier() (Acc: 90.0%) (RND:888)
10) NuSVC() (Acc: 88.75%) (RND:5)
11) MLPClassifier(hidden_layer_sizes=(100, 100, 100, 100), max_iter=1000) (Acc: 88.75%) (RND:5)
12) LinearSVC(max_iter=10000) (Acc: 88.75%) (RND:24)
13) KNeighborsClassifier() (Acc: 88.75%) (RND:5)
14) DecisionTreeClassifier() (Acc: 88.75%) (RND:321)
15) MLPClassifier(hidden_layer_sizes=(100, 100), max_iter=1000) (Acc: 87.5%) (RND:5)
16) ExtraTreeClassifier() (Acc: 87.5%) (RND:321)
17) MLPClassifier(hidden_layer_sizes=(100, 100, 100), max_iter=1000) (Acc: 86.25%) (RND:5)
18) GradientBoostingClassifier() (Acc: 85.0%) (RND:5)
19) GaussianProcessClassifier() (Acc: 82.5%) (RND:5)
20) AdaBoostClassifier() (Acc: 76.25%) (RND:5)
21) RadiusNeighborsClassifier(radius=60) (Acc: 23.75%) (RND:5)

Wie man sehen kann würden fast alle Algorithmen eine Genauigkeit von mindestens 85% erreichen, und einige sogar über 90%. Die besten drei sind einmal Nearest Centroid, ein neutrales Netz und Randome Forest. Überrascht hat mich nur das Nearest Centroid so gut abgeschnitten hat, ist es doch einer der einfacheren Algorithmen, der salopp gesagt die Mittelpunkte im 6-dimensionalen Bassisationsraum für die einzelnen Räume bestimmt und dann bei der abfrage den Raum zuordnen der am nächsten ist. Das er so gut abgeschnitten hat, könnte vielleicht auch dran liegen das ich für die zweite Datenaufnahme faul wahr und die Position zwischen den einzelnen aufnahmen im selben Raum nicht geändert habe. Nichtsdestotrotz müsste ich das ganze auf einem low power system umsetzten wäre das jetzt auf jeden meine erste Wahl. Leider hat dieser Algorithmus nicht viele Parameter, weshalb ich nicht glaube das ich das weiter verbessern kann, ich versuche es trotzdem mal:

In [17]:
import pickle
from sklearn.model_selection import GridSearchCV

parameters = {'shrink_threshold':range(0,100),
              'metric':['euclidean', 'manhattan', 'braycurtis', 'canberra', 'chebyshev', 'correlation', 'cosine', 'dice', 'hamming', 'jaccard', 'kulsinski', 'mahalanobis', 'matching', 'minkowski', 'rogerstanimoto', 'russellrao', 'seuclidean', 'sokalmichener', 'sokalsneath', 'sqeuclidean', 'yule']}

clf = GridSearchCV(NearestCentroid(), parameters, n_jobs=-1, verbose=5)
clf.fit(X_train, y_train)
acc = clf.score(X_test, y_test)
print("Mean accuracy of nearest centroid classifier: {}%".format(acc*100))
print("Beste parameters: {}".format(clf.best_estimator_.get_params()))
Fitting 5 folds for each of 2100 candidates, totalling 10500 fits
Mean accuracy of nearest centroid classifier: 91.25%
Beste parameters: {'metric': 'euclidean', 'shrink_threshold': 0}

Wie erwarte lässt sich nichts mehr verbessern, also trainiere ich es diesmal mit dem gesamten Datensatz und probiere es gleich mal in Wirklichkeit aus:

In [18]:
clf = NearestCentroid()
clf.fit(X, y)
with open("Nearest-Centroid.sk","wb") as f:
    pickle.dump(clf, f)

for loop in range(20):
    wait_for_key()     # Auf den Tastendruck warten
    scanres = np.array(do_scan2()) # Scan durchführen 
    roomID = clf.predict(scanres) # Raum bestimmen
    do_switch(int(roomID)) # Licht Schalten
    print("Found Room {}".format(roomID))
Found Room 1
Found Room 1
Found Room 2
Found Room 4
Found Room 2
Found Room 2
Found Room 2
Found Room 4
Found Room 4
Found Room 3
Found Room 3
Found Room 3
Found Room 4
Found Room 2
Found Room 4
Found Room 4
Found Room 3
Found Room 4
Found Room 2
Found Room 2

Wie zuvor kann man es von der Ausgabe nicht sehen, aber das Ergebnis ist nicht besonders schlecht aber auch nicht besonders gut: Vor allem der unterschied zwischen Raum 3 und 4 bereitet Probleme und selbst in Raum 2, glaubt er hin und wieder in Raum 4 zu sein. Vielleicht hat die Art wie ich die zweit Datensammlung durchgeführt habe dazu geführt das Nearest Centroid besser performt hat als es in Wirklichkeit ist. Das zeigt wieder da man sich immer auch fragen muss wie und unter welchen Voraussetzungen die Daten erhoben wurden.

Als nächstes nehme ich mir den Random Forest klassifiziere vor, welcher im Prinzip ganz viele Entscheidungsbäume, wie in meinem initialen Modell, enthält die auf unterschiedliche teile das Daten trainiert wurden. Wie zuvor versuche ich mit Hilfe von GridSearchCV die Parameter Kombination zu finden die Genauigkeit des Modells verbessert. Ich versuche hier nur ein paar wenige aber wichtige Parameter zu testen: Einmal die Anzahl Entscheidungsbäume im Wald, die Tiefe der einzelnen Entscheidungsbäume sowie die Mindestanzahl an Datenpunkte per Blatt. Zusätzlich variiere ich noch initialen zustand des Zufallsgenerators, um möglichen Variationen aus den weg zu gehen.

In [19]:
parameters = {'n_estimators': [25,50,75,100,150,200],
              'min_samples_leaf':range(1,5),
              'max_depth':range(1,15),
              'random_state':[5,123,42,1337,321,111,555,24,777,888]
            }
rfc = RandomForestClassifier()
clf = GridSearchCV(rfc, parameters, n_jobs=-1, verbose=4)
clf.fit(X_train, y_train)
acc = clf.score(X_test, y_test)
print("Mean accuracy of Random forrest Classification: {}%".format(acc*100))
print("Beste parameters: {}".format(clf.best_estimator_.get_params()))
Fitting 5 folds for each of 3360 candidates, totalling 16800 fits
Mean accuracy of Random forrest Classification: 93.75%
Beste parameters: {'bootstrap': True, 'ccp_alpha': 0.0, 'class_weight': None, 'criterion': 'gini', 'max_depth': 8, 'max_features': 'auto', 'max_leaf_nodes': None, 'max_samples': None, 'min_impurity_decrease': 0.0, 'min_impurity_split': None, 'min_samples_leaf': 2, 'min_samples_split': 2, 'min_weight_fraction_leaf': 0.0, 'n_estimators': 100, 'n_jobs': None, 'oob_score': False, 'random_state': 5, 'verbose': 0, 'warm_start': False}

Hier konnte ich das Genauigkeit des Modells nochmal leicht auf 93.75% verbessern. Nicht viel, aber ich glaube nicht das es nur einer von den Modellen mit den zu Verfügung stehenden Daten es auf 100% schafft. Wie zuvor mache ich eine real World Test, weil wie schon gesehen gibt es hier wohl einen unterschied zwischen Theorie und Praxis:

In [20]:
clf = RandomForestClassifier(n_estimators=100, min_samples_leaf=2, max_depth=8)
clf.fit(X, y)
with open("Random-Forest.sk","wb") as f:
    pickle.dump(clf, f)

for loop in range(20):
    wait_for_key()     # Auf den Tastendruck warten
    scanres = np.array(do_scan2()) # Scan durchführen 
    roomID = clf.predict(scanres) # Raum bestimmen
    do_switch(int(roomID)) # Licht Schalten
    print("Found Room {}".format(roomID))
Found Room 1
Found Room 1
Found Room 1
Found Room 2
Found Room 2
Found Room 2
Found Room 3
Found Room 3
Found Room 4
Found Room 3
Found Room 3
Found Room 4
Found Room 4
Found Room 4
Found Room 2
Found Room 2
Found Room 4
Found Room 2
Found Room 2
Found Room 1

Nach der Runde durch die Räume kann ich sagen, das der Random Forest Klassifiziere auf jeden Fall besser ist, aber leider wird doch noch das Licht in Raum 4 geschaltet, wenn man sich eigentlich im Raum 2 oder 3 befindet. In den Daten scheint der Unterscheidung zwischen Raum 4 und Raum 2 und 3 nicht ganz so klar zu sein, wie es die Modell gerne hätten.

Jetzt kommen ich zum letzten Kandidaten, einem neutralen Netz, was eigentlich die Standard Methode geworden ist, vorausgesetzt es sind genügend Daten zum trainieren da. Wie zuvor möchte ich schauen ob ich das Modell verbessern kann, aber alle möglichen Parameteroptionen durch zu spielen dauert einfach zu lange, weshalb ich nur eine Auswahl von 10000 zufällig ausgewählten Varianten testen werde. Es werden wieder nur die üblichen Parameter variiert: die Anzahl der Neuronen, die Aktivierungsfunktion, der Optimierungsalgorithmus, Parameter für die Lernrate, sowie verschieden initiale Zustände für den Zufallsgenerator:

In [21]:
from sklearn.model_selection import RandomizedSearchCV
parameters = {'mlpclassifier__hidden_layer_sizes':[(8),(16),(32),(64),(96),(100),(128),(192),(256),(512),(1024)], #,(8,8),(16,16),(32,32),(64,64),(128,128),(256,256),(512,512),(1024,1024),(8,8,8),(16,16,16),(32,32,32),(64,64,64),(128,128,128),(256,256,256),(512,512,512),(1024,1024,1024)], #range(128),   #
              'mlpclassifier__activation':['identity', 'logistic', 'tanh', 'relu'],
              'mlpclassifier__solver':['lbfgs', 'sgd', 'adam'],
              'mlpclassifier__learning_rate':['constant', 'invscaling', 'adaptive'],
              'mlpclassifier__learning_rate_init':[0.1, 0.01, 0.001, 0.0001],
              'mlpclassifier__random_state': [5,123,42,1337,321,111,555,24,777,888]
             }
pipe = make_pipeline(StandardScaler(), MLPClassifier(max_iter=10000))
clf = RandomizedSearchCV(pipe, parameters, n_iter=10000,n_jobs=-1, verbose=5) #, random_state=123)
clf.fit(X_train, y_train)
acc = clf.score(X_test, y_test)
print("Mean accuracy of MLPC: {}%".format(acc*100))
Fitting 5 folds for each of 10000 candidates, totalling 50000 fits
Mean accuracy of MLPC: 92.5%

Leider ließ sich hier keine Verbesserung finden, weshalb ich mit den Standard Einstellungen weiter arbeite.

Rein Zahlenmäßig schneidet das Neural Netz schlechter ab als der Random Forest Klassifiziere, aber ich will erst schauen wie sich das Netzwerk in Aktion schlägt, bevor ich dem zustimmen kann.

In [22]:
clf = make_pipeline(StandardScaler(), MLPClassifier(max_iter=10000))
clf.fit(X, y)
with open("MLPC.sk","wb") as f:
    pickle.dump(clf, f)

for loop in range(20):
    wait_for_key()     # Auf den Tastendruck warten
    scanres = np.array(do_scan2()) # Scan durchführen 
    roomID = clf.predict(scanres) # Raum bestimmen
    do_switch(int(roomID)) # Licht Schalten
    print("Found Room {}".format(roomID))
Found Room 1
Found Room 1
Found Room 2
Found Room 2
Found Room 2
Found Room 3
Found Room 3
Found Room 3
Found Room 4
Found Room 4
Found Room 4
Found Room 2
Found Room 2
Found Room 4
Found Room 2
Found Room 2
Found Room 1
Found Room 2
Found Room 1
Found Room 1

Gefühlt schneidet das Neural Netz von allen am besten ab, leider ist es auch nicht ganz perfekt, macht aber, wenn Fehler passieren, die Nachvollziehbarsten: Wenn man vor der Tür steht, kommt es schon mal vor das im Raum dahinter das Licht angeht, man könnte meine der Deluminator weiß schon wo hin man als nächstes möchte. Im Vergleich zu den anderen beiden Modellen, scheint das Neural Netz auch besser damit umzugehen können, wenn das Signal einer Basisstation mal fehlt.

Natürlich ist das Neural Netz das komplizierteste Modell, aber das schlägt sich in der eigentlichen Laufzeit kaum wieder: die meiste Zeit, ungefähr eine Sekunde, verbringt der Raspberry Pi mit dem Scannen der W-LANs, das Senden des Umschaltbefehls brauch ca. 0.1 Sekunde, weshalb die ca. 5 ms für das Neural Netz kaum ins Gewicht fallen. Das Random Forrest Modell braucht übrigens ganze 70 ms für eine vorhersage. Für weiter versuche würde ich auf jeden Fall das Neurale Netz nehmen.

Schlussendlich kann ich sagen, das ich in der Lage war eine Deluminator zu bauen, der in jedem Raum in dem ich bin das Licht (fast immer) an oder ausschalten kann. Natürlich ist er nicht so schön klein und kompakt wie der von Albus Dumbledore, aber eine Raspberry Pi Zero W mit einer 18650 Lithium Ionen Zelle als Energiequelle hätte man in ein ähnlichem Format unterbringen können. Wenn man auch mit einem einfachen Modell auskommt uns/oder noch ein bisschen mehr Zeit in seine Optimierung/Umsetzung steckt, könnte man das ganze auch in eine Arduino basierenden System umsetzten und dann könnte man das ganze wirklich in ein passendes Gehäuse unterbringen.

Natürlich ist das ganze leider kein vermarktbares Produkt, zum einen muss man erstmals die passenden Trainingsdaten für jeden Raum separat sammeln und hoffen das man genügend verteilte W-LAN Basisstation hat: Experimente mit anderen Umgebungen haben mir gezeigt das es nicht immer so gut läuft wie in meinem Fall, auch das Mauermaterial und die Raumaufteilung haben eine Raumaufteilung auf die Genauigkeit. Aber das eigentliche Problem sind die W-LAN Basistationen über die man keinen Einfluss hat, werden diese ausgetauscht oder noch schlimmer wechseln diese die Position oder ändern diese Dynamisch ihre Einstellungen, kann es dazu führen das Modell die vorher wunderbar funktioniert haben, plötzlich das Licht zwei Räume weiter ausschaltet.

Wie schon erwähnt, glaube ich nicht das es immer und überall klappen wird, aber es ist schon interessant zu sehen was alles möglich ist mit den Signalen die uns tagtäglich umgeben. Auch die Idee das ein Geräte im Haus autonom seine Position bestimmen kann, öffnet die Türe für viele Interessanten Anwendungen im Bereich SmartHome.

Nachdem ich nun meinen Deluminator jeden in meiner Familie gezeigt habe, kam die Frage auf ob ich nicht auch eine Zeitumkehrer bauen könnte, schließlich hat das ja mit dem Deluminator so gut geklappt. Leider glaube ich das dafür die z.Z. vorhanden Maschine Learning Modelle noch nicht ausreichen, mal abgesehen davon das die dazu nötige Physik nochmal ein ganz anderes Thema ist, aber mal sehen vielleicht klappt es ja doch ihrgendwie.