Machine learning – een introductie in supervised learning

Machine learning is een breed begrip en wordt vaak verwisseld en verward met termen als artificial intelligence, deep learning en soms ook met business intelligence. In dit artikel geven we een introductie van de mogelijkheden van machine learning aan de hand van een praktijkcase.

Tammo Sminia & Gerben Oostra

Artificial intelligence omvat het hele onderzoeksgebied, waarin computers beslissingen gaan nemen voor ons. We laten de computer hierbij intelligente beslissingen nemen. Een deel daarvan betreft het voorspellen van situaties. We laten een bal vallen en voorspellen welke kant hij opvalt. Dergelijke problemen kunnen beschreven worden aan de hand van deterministische regels. We specificeren de zwaartekracht, massa en versnellingen en de computer kan uitrekenen welke richting en hoe snel iets zal gaan bewegen. Eigenlijk geven we de computer de (natuurkundige) wetten, die hij slechts hoeft toe te passen.

Een alternatieve aanpak is om de computer situaties te geven en de computer zelf tot een model van de werkelijkheid te laten komen. Een model kan een paar simpele regels zijn, maar kan ook veel complexer zijn. Dat vakgebied wordt machine learning genoemd. De computer leert hoe iets werkt.

Er zijn verschillende soorten machine learning. Zo is er supervised learning, waarin zowel de situatie als het verwachte antwoord gegeven wordt. Het alternatief is unsupervised learning. Daarbij wordt een dataset aan de computer gegeven en dient de computer zelf een structuur te vinden. Het clusteren van data is een voorbeeld van unsupervised learning.

De vaak gehoorde term deep learning is een bepaalde techniek, waarmee machine learning gedaan kan worden. Er zijn veel verschillende manieren om een model te maken, waarvan deep learning er dus één van is. Bij deep learning wordt een neuraal netwerk gemaakt. Dit is een netwerk van simpele beslissingen, die gezamenlijk complexe beslissingen kunnen maken. Elke beslisser wordt een ‘node’ genoemd. Er wordt dan een netwerk opgebouwd door vele lagen van nodes te maken. De nodes van een bepaalde laag gebruiken enkel de uitkomst van nodes uit de laag ervoor. De term ‘deep’ refereert naar het aantal lagen binnen zo’n netwerk. Tegenwoordig kunnen dergelijke netwerken duizenden lagen groot zijn en daarmee zeer complexe modellen vormen.

Een machine learning case

In dit artikel zullen we ons bezighouden met supervised learning. Op basis van bestaande gegevens over verkochte huizen gaan we voorspellen wat de verkoopprijs zal zijn.

Omdat we een waarde (de prijs) voorspellen is dit een regressie probleem. Dit onderscheidt zich van een classificatie probleem, waarbij een ja/nee vraag beantwoord wordt.

Een heel simpel regressiemodel is bijvoorbeeld: prijs = oppervlakte * 3000. Dit model zal natuurlijk niet heel accuraat zijn. Met meer eigenschappen ben je in staat om complexere modellen te maken, die ook accurater kunnen zijn. Bijvoorbeeld: prijs = oppervlakte * 3000 in Amsterdam en oppervlakte * 2000 elders. In deze context worden de gebruikte eigenschappen features genoemd.

Om te weten of het model correct is en om het model vervolgens te kunnen corrigeren, hebben we ten eerste gegevens over daadwerkelijke verkoopprijzen nodig. Dit wordt een dataset genoemd en bevat zowel informatie over het huis, als de werkelijke prijs waarop het huis verkocht is. Informatie over het huis zijn verscheidene eigenschappen, zoals locatie, oppervlakte in vierkante meters en de aanwezigheid van een zwembad.

In dit artikel gebruiken we een dataset van Kaggle [1]. Kaggle is een online platform, waarop je de competitie aan kunt gaan met andere data scientists om het beste model te maken. Er zijn ook datasets beschikbaar om op te oefenen, zoals de huizen dataset.

Deze set kunnen we gebruiken om ons machine learning model te trainen. Het wordt daarom ook wel trainingsdata genoemd. Met dat model kunnen we vervolgens huizenprijzen gaan voorspellen van nieuwe huizen, waarvan de verkoopprijs nog onbekend is.

Het vervolg van dit artikel laat zien hoe je op de JVM eenvoudig een machine learning model kunt bouwen met behulp van Spark. Het gebruik van Spark heeft als bijkomend voordeel dat de oplossing ook eenvoudig op te schalen is naar een cluster. Dit zullen we in de volgende sectie uitleggen.

 

Machine learning met Spark

Voor machine learning op de JVM diende je in het verleden gebruik te maken van libraries, zoals Weka. De reden daarvoor was voornamelijk dat het concept dataset niet standaard is binnen Java. De focus is object georiënteerd, met een class instantie per entiteit in het domein. Je definieert bijvoorbeeld een class voor Huis, met eigenschappen (oppervlakte, locatie) als fields. Je bouwt vervolgens meestal een applicatie, die met instanties daarvan werkt, bijvoorbeeld een specifiek huis met 100 m2 oppervlakte.

In statistische talen, zoals R, MATLAB of Python (met pandas) speelt juist de dataset (ook wel dataframe genoemd) de hoofdrol. Er wordt in principe niet met één object gewerkt, maar juist met de hele dataset. Je kunt je een dataset voorstellen als een tabel met alle data, met daarin een kolom per eigenschap. Je werkt dus niet met individuele huizen, maar met een dataset ‘huizen’, en een kolom voor bijvoorbeeld de oppervlakte. Doordat de statistische talen daarmee werken, kunnen ze bepaalde operaties zeer snel per kolom (vectorized) uitvoeren. Een deel van de huizen dataset kan dus weergegeven worden, zoals in Tabel 1.

 

id LotArea Street LotShape Utilities LotConfig Neighborhood
1 8450 Pave Reg AllPub Inside CollgCr
2 9600 Pave Reg AllPub FR2 Veenker
3 11250 Pave IR1 AllPub Inside CollgCr
4 9550 Pave IR1 AllPub Corner Crawfor
5 14260 Pave IR1 AllPub FR2 NoRidge

 

Met de komst van ‘big data’ kom je als developer in situaties terecht, waarin de data niet meer op één JVM (server) past. Als oplossing daarvoor ontstond in eerste instantie Hadoop en later Spark. Beiden noemen zich data processing engines. Om de data te distribueren over het cluster dienen ze wel op de hele dataset te kunnen werken. De dataset moest een niveau van abstractie worden, zodat de processing engine kon bepalen hoe de data over de het cluster verdeeld kon worden.

Vanuit dat perspectief brengt Spark twee zaken naar de JVM. Ten eerste het concept dataset en ten tweede de distributie van operaties op datasets over meerdere servers. Door de introductie van het concept dataset, heeft het niet lang geduurd, voordat er ook machine learning algoritmes beschikbaar kwamen op Spark.

 

Spark Notebook (Zeppelin)

Om met Spark aan Machine Learning te doen, maken we gebruik van SparkML (Spark Machine Learning Library). In deze library zitten functies om eenvoudig de meest gebruikelijke modellen te trainen en vervolgens de getrainde modellen te gebruiken om voorspellingen mee te doen.

Het ontwikkelen van een goed data science model is vaak een erg exploratief proces. Het is van tevoren niet duidelijk welke oplossing de beste resultaten geeft. We zullen verderop uitleggen in welke stappen een model opgebouwd kan worden. Het definiëren van die stappen gebeurt in een exploratief proces, zodat de stappen iteratief verbeterd worden. Daarbij dient na elke wijziging gecontroleerd te worden of het resulterende model betere voorspellingen levert. Dit vereist zeer korte cyclussen tussen bedenken, uitvoeren en evalueren.

Om dit iteratief proces te ondersteunen, gebruiken we Zeppelin, een eenvoudige editor waarin je commentaar en code kunt afwisselen. Een voordeel van Zeppelin is dat je code blokken van verschillende talen, zoals Scala, Shell, SQL en Markdown kunt afwisselen.

Zeppelin werkt goed met zowel Spark als met Scala en maakt je als ontwikkelaar daarom zeer productief in het exploratieve proces. Het resultaat zal een Zeppelin notebook zijn, waarbij de code in combinatie met commentaar een verslag (notebook) is geworden.

De opgebouwde machine learning pipeline zal bestaan uit een paar stappen: data inlezen, feature generation, data splitsen in train- en testset, model trainen en model evaluatie. Deze stappen zullen we hieronder uitwerken.

 

Data inlezen

De eerste stap is de data in te lezen als een dataset en het beschikbaar te stellen voor spark sql.

 

%spark

val df = spark.read.option(“header”, true)

.option(“inferSchema”, “true”)

.csv(“file:/data/train.csv”)

.na.drop()

df.registerTempTable(“house”)

 

 

Om inzicht te krijgen in de data kun je eenvoudig grafieken maken.

 

%sql

select GrLivArea as x, SalePrice as y from house

 

Feature generation

Voordat een model met data getraind kan worden, dient de data wel in het juiste formaat te zijn. In het algemeen betekent dat, dat de data in ieder geval numeriek moet zijn. Er zijn ook nog een boel verrijkingen van de data mogelijk met als doel om het model meer informatie te geven. Dit wordt dan ook feature engineering genoemd. In dit voorbeeld beperken we ons tot het numeriek maken van de data. Categorieën zetten we daarom om met behulp van one-hot encoding. Daarmee wordt een kolom gemaakt voor elke mogelijke optie van een categorie. Deze bevat een 1 als de rij (het huis) van die bepaalde categorie is en 0 als dit niet het geval is (zie Tabel 2).

 

Neighborhood
Collgr
Veenker
Somerst

 

Neighborhood-Collgr Neighborhood-Veenker Neighborhood-Somerst
1 0 0
0 1 0
0 0 1

 

Om dit te bewerkstelligen, bepalen we eerst welke kolommen getallen zijn en welke categorisch. Ook geven we aan welke kolom (SalePrice) voorspeld dient te worden:

 

%spark

val vars_num = df.schema.filter(_.dataType.toString == “IntegerType”).map(_.name).filter(List(“SalePrice”,”id”).contains(_)).toArray

val vars_categ = df.schema.filter(_.dataType.toString == “StringType”).map(_.name).toArray

val var_y = “SalePrice”

 

Met die informatie kunnen we de numerieke kolommen naar een Double omzetten:

 

 

%spark

val toDouble = udf[Double, Int]( _.toDouble)

val df_double = vars_num.foldLeft(df)((dataframe, var_num) => dataframe.withColumn(var_num + “_”, toDouble(dataframe(var_num))))

 

De resterende categorische kolommen (vars_categ) transformeren we naar een one hot encoding met behulp van een pipeline. De numerieke waarden en de vectoren van de one hot encodings worden tenslotte tot één lange feature vector omgezet.

 

%spark

import org.apache.spark.ml.feature.{OneHotEncoder, StringIndexer, VectorAssembler}

import org.apache.spark.ml.Pipeline

val stringIndexers = vars_categ.map(colName => new StringIndexer().setInputCol(colName).setOutputCol(colName + “_indexed”))

val oneHotEncoders = vars_categ.map(colName => new OneHotEncoder().setInputCol(colName + “_indexed”).setOutputCol(colName + “_ohe”).setDropLast(false))

val catAssembler = new VectorAssembler().setInputCols(vars_categ.map(_ + “_ohe”)).setOutputCol(“catFeatures”)

val featureAssembler = new VectorAssembler().setInputCols(vars_num.map(_ + “_”) :+ “catFeatures”).setOutputCol(“features”)

val pipeline = new Pipeline().setStages(

stringIndexers ++

oneHotEncoders ++

Array(catAssembler, featureAssembler))

 

Het voordeel van een pipeline is dat toekomstige, nieuwe data op een vergelijkbare manier omgezet kan worden. Als voorbeeld transformeren we eerst de dataframe met doubles (df_double):

 

%spark

val pipelineModel = pipeline.fit(df_double)

val transformedDF = pipelineModel.transform(df_double)

 

Data splitsen in train- en testset

Het model gaan we trainen met behulp van huizen met bekende prijzen. Als je maar een voldoende complex model maakt, kun je elk huis perfect gaan voorspellen. Dat betekent echter nog niet dat het model ook goed is in het maken van voorspellingen op nieuwe data. Dit probleem wordt overfitting genoemd: het model is overfit op de specifieke train data en is dus niet generiek genoeg voor nieuwe data.

Het simpelste voorbeeld daarvan is een model, dat bestaat uit een lijst van alle huizen met bekende prijzen. Met een dergelijk model kun je de prijs van elk van die huizen exact voorspellen, maar heb je geen enkel idee van de prijs voor nieuwe huizen.

Om dit te voorkomen, verdelen we de data in twee delen: een trainset en een testset. De trainset gebruiken we om het model te leren. De testset gebruiken we daarna om te evalueren hoe goed het model is. Op deze manier krijgen we een reëel beeld van de uiteindelijke kwaliteit van onze voorspellingen.

 

De bijbehorende code is kort en krachtig.

 

%spark

val Array(trainingData, testData) = transformedDF.randomSplit(Array(0.7, 0.3))

 

In de voorgaande feature generation houden we het simpel. We veranderen eigenlijk enkel de datatype van de kolommen. Er zijn veel complexere transformaties mogelijk, die vaak de gehele dataset gebruiken. Zo kun je bijvoorbeeld met principle component analysis (PCA) het aantal kolommen verminderen. De originele kolommen (de dimensies van je dataset) worden getransformeerd naar nieuwe dimensies. Om die transformatie te bepalen, wordt er eigenlijk een transformatiemodel gemaakt. Om te voorkomen dat die transformatie ook informatie gebruikt van de testset, dien je dergelijke transformaties pas te doen na de train- en testsplit. In dat geval wordt de feature generation namelijk een verlengde van je model.

Model trainen (Random forest)

Een veelgebruikt model is een random forest. Dit model is eenvoudig toe te passen en geeft vaak goede modellen, die niet overfit zijn. Een random forest is een verzameling van kleinere beslisbomen. Elke boom op zich is niet sterk voorspellend, maar alle bomen gecombineerd zijn dat wel. De verzameling bomen wordt random genoemd, omdat elke boom getraind wordt op een willekeurige fractie van de gehele data. Deze fractie bevat slechts een paar rijen (huizen) en ook maar een deel van de features (eigenschappen). Door de handicap waarmee elke individuele boom wordt gemaakt, kan die boom niet overfitten. Elke boom beschrijft eigenlijk een beperkt aantal aspecten van slechts een deel van de dataset. Alle bomen gezamenlijk beschrijven dan de hele dataset, zonder te overfitten.

In de volgende code bouwen we een variant van random forest, namelijk een gradient boosted regression trees op. De DecisionTreeRegressor verwijst naar een individuele boom, waarmee het forest opgebouwd wordt.

 

%spark

import org.apache.spark.ml.Pipeline

import org.apache.spark.ml.evaluation.RegressionEvaluator

import org.apache.spark.ml.feature.VectorIndexer

import org.apache.spark.ml.regression.{DecisionTreeRegressionModel,GBTRegressionModel}

import org.apache.spark.ml.regression.{DecisionTreeRegressor,GBTRegressor}

// Train a DecisionTree model.

val dt = new DecisionTreeRegressor()

  .setLabelCol(“SalePrice”)

  .setFeaturesCol(“features”)

val gbt = new GBTRegressor()

  .setLabelCol(“SalePrice”)

  .setFeaturesCol(“features”)

  .setMaxIter(25)

// Chain indexer and tree in a Pipeline.

val pipeline = new Pipeline()

  .setStages(Array(gbt))

// Train model. This also runs the indexer.

val model = pipeline.fit(trainingData)

 

Kenmerkend aan data science vraagstukken is dat er niet één model altijd het beste werkt. Er zijn altijd verschillende mogelijkheden om het probleem op te lossen en op voorhand is vaak niet duidelijk welk model het beste zal werken. Dit heet ook wel “The no free lunch theorem”.

Het random forest heeft bijvoorbeeld een aantal parameters, waarmee de werking aangepast kan worden per situatie. De individuele decision trees kunnen groter gemaakt worden (meer of minder features gebruiken) en een grotere of kleinere fractie van de data krijgen.

Daarnaast zijn er nog legio andere modellen, bijvoorbeeld (geregulariseerde) lineaire regressie, nearest neighbor en support vector machines. Ieder heeft zo z’n sterke en zwakke eigenschappen. Dat behandelen wordt echter wat te veel voor dit artikel.

 

Model evaluatie

Met dit model kunnen we vervolgens ook prijzen van andere huizen voorspellen. De eigenschappen van het huis in kwestie gaan erin en een voorspelling van de prijs komt eruit. Intern wordt dit berekend door elke individuele beslisboom een voorspelling te laten maken voor de prijs. Het gemiddelde van al die voorspellingen wordt de uiteindelijke voorspelling.

Omdat we een testset apart hebben gehouden, kunnen we zien hoe goed de voorspellingen van ons model zijn. Daarvoor voorspellen we alle huizenprijzen in de testset en vergelijken we die met de echte prijs. Listing 9 geeft aan hoe dat gedaan kan worden.

 

%spark

// Make predictions.

val predictions = model.transform(testData)

// Select example rows to display.

predictions.select(“prediction”, “SalePrice”, “features”).show(5)

// Select (prediction, true label) and compute test error.

val evaluator = new RegressionEvaluator()

  .setLabelCol(“SalePrice”)

  .setPredictionCol(“prediction”)

  .setMetricName(“rmse”)

val rmse = evaluator.evaluate(predictions)

println(“Root Mean Squared Error (RMSE) on test data = ” + rmse)

 

Hierin zien we een aantal voorbeelden van voorspellingen ten opzichte van de waarheid. Om een eenduidige maat te hebben van de kwaliteit wordt ook de Root Mean Squared Error (RMSE) gegeven. Deze geeft aan hoe groot de fout gemiddeld was, door de fouten die gemaakt zijn op de testset te kwadrateren en vervolgens te middelen. Dit geeft de spreiding aan van de voorspellingen ten opzichte van de waarheid, vergelijkbaar aan de standaarddeviatie.

 

Conclusie

De bovenstaande code laat zien dat machine learning nu ook eenvoudig op de JVM beschikbaar is. Door gebruik te maken van een platform als Spark, is de oplossing ook meteen schaalbaar. Aan de lezer de uitdaging om bovenstaande eerste opzet uit te breiden en wellicht in de toekomst een Kaggle competitie te winnen.

 

Links:

  1. Kaggle: kaggle.com/c/house-prices-advanced-regression-techniques