Willkommen bei "Komm' wir spielen NSA!"!
Dieses Notebook ist eine kleine Einführung in die Data Science Welt mit Python, dem Notebook-System Jupyter, dem Datenanalyse-Framework Pandas, der Visualisierungsbibliothek "matplotlib" und der Machine-Learning-Bibliothek "scikit-learn".
Die Aufgabe ist, aus dem Mitschnitt von Telefon-Metadaten herauszufinden, wo die von uns verdächtigte Person namens "John Doe" wohnt. Ganz nebenbei lernen wir die wichtigsten Dinge im Umgang mit Python & Co. .
Das Notebook erklärt hierzu immer die Funktionsweisen und die Aufgaben der notwendigen Analyseschritte.
Als erstes wollen wir über den in Python üblichen Weg das Modul "Pandas" einbinden.
Generell können sog. Module auf mehrere Arten eingebunden werden:
import <modul>
, z. B. import pandas
import <modul> as <name>
, z. B. import pandas as pd
from <hauptmodul> import <modul>
, z. B. from matplotlib import pyplot
Wir möchten gerne das Modul "Pandas" als pd
einbinden. Die Zelle können wir dann mit dem Tastenkürzel Shift
+ Enter
ausführen.
In [1]:
import pandas as pd
Ob das Importieren des Moduls auch wirklich funktioniert hat, können wir prüfen, in dem wir mit dem pd
-Modul arbeiten. Dazu hängen wir an die pd
-Variable den ?
Operator an und führen die Zelle aus. Es erscheint die Dokumentation des Moduls im unteren Bereich des Notebooks. Diesen Bereich können wir durchlesen und mit der Taste ESC
auch wieder verschwinden lassen.
In [2]:
pd?
Die weitere Funktionalität der Pandas-Bibliothek können wir erkunden, indem wir die Methoden von Pandas ansehen. Dazu verwenden wir pd.
und nutzen die integrierte Autovervollständigung von Jupyter mittels der Tabulatortaste Tab
, um zu sehen, welche Methoden uns Pandas bietet. Gehen wir dann mit der Pfeiltaste unten
z. B. auf Categorical
, drücken Enter
und schließend Shift
+ Tab
, dann erscheint die Signatur des entsprechenden Funktionalität und der Ausschnitt der Hilfedokumentation. Bei zweimaligem Drücken von Shift
+ Tab
erscheint die Hilfe vollständig.
In [3]:
pd.Categorical
Out[3]:
Als nächstes Laden wir Telefon-Metadaten (sog. CDRs: Call Data Records) aus einer Datei mit kommaseparierten Daten (einer "CSV"-Datei: "Comma-separated values").
Pandas bietet hierzu eine Methode namens read_csv
. Diese nimmt als ersten Parameter einen Pfad entgegen. Diverse andere Argumente können angegeben werden, um spezielle Varianten von CSV-Dateien einlesen zu können. Z. B.
sep
: Trennzeichen für Daten in einer Zeile wie etwa sep=";"
encoding
: andere Zeichenkodierungen wie etwa encoding="latin-1"
dtype
: Zuordnung von Spalten zu speziellen Datentypen wie etwa dtype={ 0:str }
Lesen wir erst einmal die Datei data/CDR_data.csv
ohne jegliche Argumente ein. Diese Datei enthält die für 10 Mobilfunknutzer gesammelten Telefon-Metadaten über drei Jahre hinweg. Folgende Informationen sind darin enthalten:
In
: Die anrufende Telefonnummer Out
: Die ausgehende TelefonnummerDirection
: Die Richtung des TelefonatsCallTimestamp
: Der Zeitstempel des AnrufsDuration
: Die Dauer des TelefonatsTowerID
: Die ID des ersten Mobilfunkmasten, der beim Anruf genutzt wurdeWir lesen die Daten ein und weisen das Ergebnis mittels =
der neuen Variable cdr
zu. Mittels der Methode head()
können wir uns dann in der Folgezeile die ersten fünf Einträge anzeigen lassen.
In [4]:
cdr = pd.read_csv('data/CDR_data.csv')
cdr.head()
Out[4]:
Als nächstes sehen wir uns mit der info()
-Methode an, was uns nun Pandas in die cdr
-Variable geschrieben hat.
In [5]:
cdr.info()
Wir sehen, das wir nun einen DataFrame
erhalten haben. Einen DataFrame
können wir uns ersteinmal wie ein Excel-Arbeitsblatt vorstellen. Er besteht aus Zeilen ("rows") und Spalten ("columns"). Wir können auf Zeilen und Spalten Operationen beliebiger Art ausführen lassen. Vor allem die spaltenweise Verarbeitung ist hier sehr effizient gelöst.
Speziell unser DataFrame
enthält nun 53188 Einträge von Telefonaten. Wir können das prüfen, indem wir über die in Python eingebauten len
-Methode auf den unseren cdr
-DataFrame
anwenden.
In [6]:
len(cdr)
Out[6]:
Zudem sehen wir Informationen über die Datentypen in dem Dataframe
. Die beiden ersten Spalten In
und Out
sind vom Typ int64
, also Ganzzahlen, welche als 64-Bit gespeichert werden (können also sehr große Zahlen werden). Die vier anderen Spalten sind vom Typ object
, was in unserem Fall soviel bedeutet, dass wir hier ersteinmal reine Zeichenketten bzw. Texte vorliegen haben.
Alle Spalten werden als non-null
angezeigt. D. h. dass in jeder Reihe für jede Spalte immer ein Eintrag vorhanden ist.
Für einzelne Spalten eines DataFrame
s (den sog. Series
) können ganz schnelle Auswertungen vornehmen. Z. B. können wir einmal nachsehen, wie lange die durchschnittliche Dauer aller Telefonate ist. Vorher müssen wir jedoch die Spalte noch in einen passenden Datentyp umwandeln. Pandas bietet uns hier verschiedene Helfermethoden, um dies zu bewerkstelligen:
to_numeric
wandelt Daten in Zahlen umto_datetime
wandelt Daten in Datumsangaben umto_timedelta
wandelt Daten in Zeitangaben umHierzu nutzen wir die Spalte Duration
und wenden darauf die Methode mean()
an. Den Zugriff auf die Spalte können wir mittels zwei unterschiedlicher Schreibweisen vornehmen:
cdr['Duration']
. Ein wesentlicher Vorteil dieser Schreibweise ist, dass hier jeder Spaltenname (z. B. mit Leerzeichen oder Zahlen) angesprochen werden kannDataFrame
aufrufen: cdr.Duration
. Ein Vorteil dieser Schreibweise ist, dass hier die Autovervollständigung funktioniert. Ich bevorzuge diese Schreibweise, soweit das möglich ist.Wir wandeln die Spalte CallTimestamp
mittels pd.to_datetime
und die Spalte Duration
mittels pd.to_timedelta
entsprechend ihrer passenden Datentypen um. Die Ergebnisse speichern wir gleich wieder in die entsprechenden Spalten. Mit info()
auf den DataFrame
können wir nun kontrollieren, ob die Umwandlung erfolgreich war.
In [7]:
cdr.CallTimestamp = pd.to_datetime(cdr.CallTimestamp)
cdr.Duration = pd.to_timedelta(cdr.Duration)
cdr.info()
Nun können wir auf der Spalte Duration
den Durchschnitt mit mean()
berechnen.
In [8]:
cdr.Duration.mean()
Out[8]:
Wir sehen, dass die durchschnittliche Anrufdauer bei etwa sechs Minuten liegt.
In [17]:
phone_owners = pd.read_excel("data/phoneowners.xlsx")
phone_owners.head()
Out[17]:
Die Spalten dieser Datei wollen wir nun an den obigen cdr
-DataFrame
mit anhängen. Hierzu verwenden wir die Funktion pd.merge
, welche es uns erlaubt, beliebige Spalten aus unterschiedlichen DataFrame
s zusammenzuführen.
pd.merge(
left,
right,
left_on=None,
right_on=None,
...)
Das Ergebnis ist ein neuer DataFrame
, den wir in der Variable cdr_names
speichern. Wir zeigen hier einmal nur die ersten fünf Einträge der relevanten Spalten in der Ausgabe an, in dem wir einen Sub-DataFrame
mit den eckigen, doppelten Klammern erzeugen und darauf direkt die head()
-Methode aufrufen.
In [15]:
cdr_names = pd.merge(cdr, phone_owners, left_on='In', right_on='number')
cdr_names[['In', 'number', 'name']].head()
Out[15]:
Da wir nun wissen, welche Telefonnummer John Doe gehört, können wir die entsprechenden Datensätze herausfinden und alle anderen Datensätze ignorieren. Dazu selektieren wir nun mit Hilfe der Selektor-Schreibweise die entsprechenden Datensätze.
Der Selektor wird mit einer offenen, eckigen Klammer [
begonnen und endet mit einer geschlossenen, eckigen Klammer ]
. Innerhalb der eckigen Klammern wir eine Series
mit boolschen Werten erwartet, also eine Spalte, die pro Eintrag entweder wahr (True
) oder (False
) enthält.
Wir schaffen uns solche eine Spalte, in dem wir jetzt nachsehen, ob ein Eintrag in der Spalte name
unseres DataFrame
s cdr_names
den Namen "John Doe" enthält.
In [19]:
ist_john_doe = cdr_names.name == "John Doe"
ist_john_doe.head()
Out[19]:
Die obige Hilfevariable können wir nun in den Selektor schreiben und erhalten damit einen DataFrame
, welcher nur die Anrufe von John Doe enthält. Das Ergebnis schreiben wir in die Variable john_doe
.
In [20]:
john_doe = cdr_names[ist_john_doe]
john_doe.head()
Out[20]:
Im nächsten Schritt interessiert uns, wo John Doe überall telefoniert. Wir wissen dank der TowerId
, an welchem Mobilfunkmasten John Doe eingeloggt war, als das Telefonat stattgefunden hatte. In der Datei data/towers.csb
haben wir zudem die Informationen, an welcher geografischen Koordinate sich ein Mobilfunkmast befindet. Diese Angaben sind pro TowerId
als Breitengrad und Höhengrad abgelegt. Zuerst laden wir die entsprechende CSV-Datei mittels read_csv()
. Wir geben zusätzlich mittels index_col=0
mit an, dass wir die erste Spalte im DataFrame
als Index verwenden wollen.
In [26]:
towers = pd.read_csv("data/towers.csv", index_col=0)
towers.head()
Out[26]:
Zum Zusammenführen der Daten mit unseren bestehenden CDR-DataFrame
können wir diesesmal die join()
-Methode verwenden, da wir im towers
DataFrame
einen Index auf die TowerID
-Spalte angelegt haben. Die join()
-Methode kann nun auf dieser Basis die Daten zusammenführen. Dazu müssen wir noch angeben, welche Spalte im john_doe
-DataFrame
die Index-Spalte im anderen DataFrame
repräsentiert. Das Ergebnis speichern wir in der Variable john_doe_towers
.
In [34]:
john_doe_towers = john_doe.join(towers, on='TowerID')
john_doe_towers.head()
Out[34]:
Nun können wir irgendwie nicht mehr erwarten zu sehen, wo die Telefonate durchgeführt wurden. Dazu plotten wir mit der Visualisierungsbibliothek "matplotlib" die entsprechenden Koordinaten der Mobilfunkmasten.
In [37]:
import matplotlib.pyplot as plt
import matplotlib
# sagt Jupyter, dass die Plotausgabe direkt im Notebook passieren soll
%matplotlib inline
# macht die Plots schöner
matplotlib.style.use('ggplot')
In [47]:
ax = john_doe_towers.plot.scatter(
x='TowerLon',
y='TowerLat',
alpha=0.1,
title='Anruforte',
s = 100)
In [48]:
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters = 2)
data = pd.concat([john_doe_towers.TowerLon, john_doe_towers.TowerLat], axis = 1)
labels = kmeans.fit_predict(data)
centroids = kmeans.cluster_centers_
ax.scatter(x = centroids[:, 0], y = centroids[:, 1], c = 'r', marker = 'x', s = 100)
ax.figure
#showandtell() # TODO: Comment this line out when you're ready to proceed
Out[48]:
In [49]:
centroids
Out[49]:
In [ ]:
joined['CallTimestamp'] = pd.to_datetime(joined['CallTimestamp'])
weekdays = joined['CallTimestamp'].dt.dayofweek.isin(Workweek) & joined['CallTimestamp'].dt.hour.isin(range(8,18))
dfweekdays = joined[weekdays]
dfweekdays.head()
In [ ]:
targetname = 'John Doe'
user1 = joined[joined['Name'] == targetname]
#user1 = user1[weekdays]
user1 = user1.reset_index(drop=True)
user1.head()
In [ ]:
#
# INFO: The locations map above should be too "busy" to really wrap your head around. This is where domain expertise comes into play.
# Your intuition tells you that people are likely to behave differently on weekends:
#
# On Weekends:
# 1. People probably don't go into work
# 2. They probably sleep in late on Saturday
# 3. They probably run a bunch of random errands, since they couldn't during the week
# 4. They should be home, at least during the very late hours, e.g. 1-4 AM
#
# On Weekdays:
# 1. People probably are at work during normal working hours
# 2. They probably are at home in the early morning and during the late night
# 3. They probably spend time commuting between work and home everyday
#
# TODO: Add more filters to the user1 slice you created. Add bitwise logic so that you're only examining records that came in on
# weekends (sat/sun).
#
# .. your code here ..
user1['DOW'] = user1.CallTimestamp.dt.strftime("%a")
user1 = user1[(user1.DOW == 'Sat') | (user1.DOW == 'Sun')]
user1.head()
#
# TODO: Further filter it down for calls that are came in either before 6AM OR after 10pm (22:00:00). You can use < and > to compare
# the string times, just make sure you code them as military time strings, eg: "06:00:00", "22:00:00":
# https://en.wikipedia.org/wiki/24-hour_clock
#
# You might also want to review the Data Manipulation section for this. Once you have your filtered slice, print out its length:
#
# .. your code here ..
user1 = user1[(user1.CallTimestamp < "06:00:00") | (user1.CallTimestamp > "22:00:00")]
user1.head()
In [ ]:
#
# INFO: Visualize the dataframe with a scatter plot as a sanity check. Since you're familiar with maps, you know well that your
# X-Coordinate should be Longitude, and your Y coordinate should be the tower Latitude. Check the dataset headers for proper column
# feature names.
# https://en.wikipedia.org/wiki/Geographic_coordinate_system#Geographic_latitude_and_longitude
#
# At this point, you don't yet know exactly where the user is located just based off the cell phone tower position data; but
# considering the below are for Calls that arrived in the twilight hours of weekends, it's likely that wherever they are bunched up
# is probably near the caller's residence:
fig = plt.figure()
ax = fig.add_subplot(111)
ax.scatter(user1.TowerLon,user1.TowerLat, c='g', marker='o', alpha=0.2)
ax.set_title('Weekend Calls (<6am or >10p)')
#showandtell() # TODO: Comment this line out when you're ready to proceed
#
# TODO: Run K-Means with a K=1. There really should only be a single area of concentration. If you notice multiple areas that are
# "hot" (multiple areas the usr spends a lot of time at that are FAR apart from one another), then increase K=2, with the goal being
# that one of the centroids will sweep up the annoying outliers; and the other will zero in on the user's approximate home location.
# Or rather the location of the cell tower closest to their home.....
#
# Be sure to only feed in Lat and Lon coordinates to the KMeans algo, since none of the other data is suitable for your purposes.
# Since both Lat and Lon are (approximately) on the same scale, no feature scaling is required. Print out the centroid locations and
# add them onto your scatter plot. Use a distinguishable marker and color.
#
# Hint: Make sure you graph the CORRECT coordinates. This is part of your domain expertise.
#
# .. your code here ..
In [ ]:
#coordinates = "" + centroids[0][1].to_string().split('.')[0] + "°" + centroids[0][1].split('.')[1][0] + centroids[0][1].split('.')[1][1] + centroids[0][0]
#str.split(' ', 1 )
centroids
difference1 = centroids[0][1] - centroids[1][1]
difference2 = centroids[0][0] - centroids[1][0]
difference1 = 0.5 * difference1
difference2 = 0.5 * difference2
coordinate1 = centroids[0][1] + difference1
coordinate2 = centroids[0][0] + difference2
coordinates = str(coordinate1) + " " + str(coordinate2)
coordinates
#-96°90'92.4672"N 96°56'57.3"W