library(tidyverse)
library(mlr)
library(parallelMap)
Introduction
The objective of this analysis is to show how one can use the amazing tools available in R to create a model and take it into a production environment. The dataset used comes from Kaggle (https://www.kaggle.com/mlg-ulb/creditcardfraud/) and contains a number of transactions done by credit card and some of them are flagged as fraudulent.
The data
The data has been modified before made available, most of the features have been compressed into 28 variables probably using a PCA algorithm. That leaves us with 28 unnamed variables v1:v28 and 3 more variables, Time, Amount and Class.
Class is our target variable where 0 means not fraud and 1 means fraud. The feature time only contains the seconds elapsed in between transactions most likely is not useful for our model creation.
df <- read_csv("./input/creditcard.csv", progress = FALSE, col_types = cols())
df %>% head()
df$Class <- factor(df$Class)
df <- data.frame(df)
The dataset is highly unbalanced with only a small percentage of the observations being fraudulent. Less than 0.2% of the transactions are fraudulent. This makes it tricky to effectively split the dataset into two.
table(df$Class)
prop.table(table(df$Class))
At this stage we divide the data into train and test datasets. One is used for model calibration and the other for validation.
Model selection
A boosted model will be used in this analysis, a more in depth look at models available would be recommended however this is more of a high level exercise so we will assume that a decision has been made of using a Gradient Boosting Machine (GBM) that should give us decent prediction results as in general it deals well with imbalanced data. We will use the Extreme Boosting (XGBM) implementations as it provides better performance and shorter training times.
Feature engineering
Again the PCA has made the possibility of performing feature engineering pointless, the data is already heavily modified. For the purpose of this example we will continue with the data as is.
Building the model
We are using mlr for our modelling needs as it makes it easy to perform hyper-parameter tuning and cross validation to improve the quality of our model. MLR is a framework that unifies different outputs from different machine learning algorithms unifying the interface and providing tools for analysis and model optimization.
We split the dataset with a 70 - 30 train test split. We verify then that the proportion is roughly the same given the low number of fraudulent transactions.
set.seed(42)
train.test <- sample(2 # either one or two
, nrow(df)
, replace = TRUE
, prob = c(0.7, 0.3))
df_train = df[train.test == 1,]
df_test = df[train.test == 2,]
prop.table(table(df_train$Class))
prop.table(table(df_test$Class))
# We will use 70% of the observations for training
# Make the task
class.task <- makeClassifTask(data = df_train, target = "Class")
# Make a learner
#gbm.learn <- makeLearner("classif.gbm")
xgbm.learn <- makeLearner("classif.xgboost")
A run with the default parameters will render the following: That is a decent result we only have one false negative i.e. a fraudulent transaction that is considered good and we have flagged 144 transactions as fraud when they were good.
While not perfect we can see that GBM can deal with the imbalance in the data.
model <- train(learner = xgbm.learn, task = class.task)
performance(predict(model,newdata = df_test), measures = list(mmce, ber, acc, fn, fp, fnr,fpr, bac))
#getParamSet(gbm.learn)
getParamSet(xgbm.learn)
For these tasks we are going to define a random search. This paper [http://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf] lays out why random search is recommended in general as you will spend less time searching and it’s guaranteed according to the authors to give better results given the same amount of iterations. For a quick sweep where you want to test few parameters
MLR supports more advanced parameter tuning functions although they are more difficult to run in parallel.
Our best results seem to come from a tree depth of 12 a subsample of 0.967, a colsample of 0.955 and a learning rate of 0.794.
The new model with the best parameters achieves a better result in every metric than before the results on the df_test dataset can be seen below. It could be refined to avoid false negatives altogether however that will depend on our business objectives and this results seem a good compromise.
lrn_best <- setHyperPars(xgbm.learn, par.vals = xgbm_tune$x)
model_best <- train(lrn_best,class.task)
performance(predict(model_best,newdata = df_test), measures = list(mmce, ber, acc, fn, fp, fnr,fpr, bac))
Now that we have our model how can we use it to make predictions? Below we see how a set of parameters called example can be passed to our model. In this case we are passing a 1 observation dataframe.
example <- df_test["6335",1:(ncol(df_test))]
example
res <- predict(model_best, newdata = example)
res <- res$data$response %>% as.character() %>% as.numeric()
res
The result is 1 for fraud which is correct in this case. However being able to predict fraud on old data is not very useful, for this model to add real value we need to be able to connect it to our existing services so we can obtain fast results and cancel the fraudulent transactions for our clients.
To do that we need to save the results on a rds file (or we pickle the model in Python lingo):
saveRDS(model_best,"./output/model_best.rds")
See More on Part 2: Deploying a model
LS0tCnRpdGxlOiAiRGV0ZWN0aW5nIEZyYXVkIGluIENyZWRpdCBDYXJkIERhdGEiCmF1dGhvcjogIkZlcm5hbmRvIE11bm96IgpvdXRwdXQ6IGh0bWxfbm90ZWJvb2sKLS0tCmBgYHtyIHNldHVwLCBpbmNsdWRlID0gRkFMU0V9CmtuaXRyOjpvcHRzX2NodW5rJHNldChldmFsID0gRkFMU0UsIGNhY2hlID0gVFJVRSkKYGBgCgoKYGBge3IgbWVzc2FnZT1GQUxTRSwgd2FybmluZz1GQUxTRX0KbGlicmFyeSh0aWR5dmVyc2UpCmxpYnJhcnkobWxyKQpsaWJyYXJ5KHBhcmFsbGVsTWFwKQpgYGAKCgojIyBJbnRyb2R1Y3Rpb24KClRoZSBvYmplY3RpdmUgb2YgdGhpcyBhbmFseXNpcyBpcyB0byBzaG93IGhvdyBvbmUgY2FuIHVzZSB0aGUgYW1hemluZyB0b29scyBhdmFpbGFibGUgaW4gUiB0byBjcmVhdGUgYSBtb2RlbCBhbmQgdGFrZSBpdCBpbnRvIGEgcHJvZHVjdGlvbiBlbnZpcm9ubWVudC4gVGhlIGRhdGFzZXQgdXNlZCBjb21lcyBmcm9tIEthZ2dsZSAoaHR0cHM6Ly93d3cua2FnZ2xlLmNvbS9tbGctdWxiL2NyZWRpdGNhcmRmcmF1ZC8pIGFuZCBjb250YWlucyBhIG51bWJlciBvZiB0cmFuc2FjdGlvbnMgZG9uZSBieSBjcmVkaXQgY2FyZCBhbmQgc29tZSBvZiB0aGVtIGFyZSBmbGFnZ2VkIGFzIGZyYXVkdWxlbnQuCgojIyBUaGUgZGF0YQoKVGhlIGRhdGEgaGFzIGJlZW4gbW9kaWZpZWQgYmVmb3JlIG1hZGUgYXZhaWxhYmxlLCBtb3N0IG9mIHRoZSBmZWF0dXJlcyBoYXZlIGJlZW4gY29tcHJlc3NlZCBpbnRvIDI4IHZhcmlhYmxlcyBwcm9iYWJseSB1c2luZyBhIFBDQSBhbGdvcml0aG0uIFRoYXQgbGVhdmVzIHVzIHdpdGggMjggdW5uYW1lZCB2YXJpYWJsZXMgdjE6djI4IGFuZCAzIG1vcmUgdmFyaWFibGVzLCBUaW1lLCBBbW91bnQgYW5kIENsYXNzLiAKCkNsYXNzIGlzIG91ciB0YXJnZXQgdmFyaWFibGUgd2hlcmUgYDBgIG1lYW5zIG5vdCBmcmF1ZCBhbmQgYDFgIG1lYW5zIGZyYXVkLiBUaGUgZmVhdHVyZSB0aW1lIG9ubHkgY29udGFpbnMgdGhlIHNlY29uZHMgZWxhcHNlZCBpbiBiZXR3ZWVuIHRyYW5zYWN0aW9ucyBtb3N0IGxpa2VseSBpcyBub3QgdXNlZnVsIGZvciBvdXIgbW9kZWwgY3JlYXRpb24uCgpgYGB7ciB3YXJuaW5nPUZBTFNFfQpkZiA8LSByZWFkX2NzdigiLi9pbnB1dC9jcmVkaXRjYXJkLmNzdiIsIHByb2dyZXNzID0gRkFMU0UsIGNvbF90eXBlcyA9IGNvbHMoKSkKZGYgJT4lIGhlYWQoKQpkZiRDbGFzcyA8LSBmYWN0b3IoZGYkQ2xhc3MpCmRmIDwtIGRhdGEuZnJhbWUoZGYpCmBgYAoKVGhlIGRhdGFzZXQgaXMgaGlnaGx5IHVuYmFsYW5jZWQgd2l0aCBvbmx5IGEgc21hbGwgcGVyY2VudGFnZSBvZiB0aGUgb2JzZXJ2YXRpb25zIGJlaW5nIGZyYXVkdWxlbnQuIExlc3MgdGhhbiAwLjIlIG9mIHRoZSB0cmFuc2FjdGlvbnMgYXJlIGZyYXVkdWxlbnQuIFRoaXMgbWFrZXMgaXQgdHJpY2t5IHRvIGVmZmVjdGl2ZWx5IHNwbGl0IHRoZSBkYXRhc2V0IGludG8gdHdvLiAKCmBgYHtyfQp0YWJsZShkZiRDbGFzcykKcHJvcC50YWJsZSh0YWJsZShkZiRDbGFzcykpCmBgYAoKQXQgdGhpcyBzdGFnZSB3ZSBkaXZpZGUgdGhlIGRhdGEgaW50byB0cmFpbiBhbmQgdGVzdCBkYXRhc2V0cy4gT25lIGlzIHVzZWQgZm9yIG1vZGVsIGNhbGlicmF0aW9uIGFuZCB0aGUgb3RoZXIgZm9yIHZhbGlkYXRpb24uCgoKIyMgTW9kZWwgc2VsZWN0aW9uCkEgYm9vc3RlZCBtb2RlbCB3aWxsIGJlIHVzZWQgaW4gdGhpcyBhbmFseXNpcywgYSBtb3JlIGluIGRlcHRoIGxvb2sgYXQgbW9kZWxzIGF2YWlsYWJsZSB3b3VsZCBiZSByZWNvbW1lbmRlZCBob3dldmVyIHRoaXMgaXMgbW9yZSBvZiBhIGhpZ2ggbGV2ZWwgZXhlcmNpc2Ugc28gd2Ugd2lsbCBhc3N1bWUgdGhhdCBhIGRlY2lzaW9uIGhhcyBiZWVuIG1hZGUgb2YgdXNpbmcgYSBHcmFkaWVudCBCb29zdGluZyBNYWNoaW5lIChHQk0pIHRoYXQgc2hvdWxkIGdpdmUgdXMgZGVjZW50IHByZWRpY3Rpb24gcmVzdWx0cyBhcyBpbiBnZW5lcmFsIGl0IGRlYWxzIHdlbGwgd2l0aCBpbWJhbGFuY2VkIGRhdGEuCldlIHdpbGwgdXNlIHRoZSBFeHRyZW1lIEJvb3N0aW5nIChYR0JNKSBpbXBsZW1lbnRhdGlvbnMgYXMgaXQgcHJvdmlkZXMgYmV0dGVyIHBlcmZvcm1hbmNlIGFuZCBzaG9ydGVyIHRyYWluaW5nIHRpbWVzLgoKIyMgRmVhdHVyZSBlbmdpbmVlcmluZwpBZ2FpbiB0aGUgUENBIGhhcyBtYWRlIHRoZSBwb3NzaWJpbGl0eSBvZiBwZXJmb3JtaW5nIGZlYXR1cmUgZW5naW5lZXJpbmcgcG9pbnRsZXNzLCB0aGUgZGF0YSBpcyBhbHJlYWR5IGhlYXZpbHkgbW9kaWZpZWQuIEZvciB0aGUgcHVycG9zZSBvZiB0aGlzIGV4YW1wbGUgd2Ugd2lsbCBjb250aW51ZSB3aXRoIHRoZSBkYXRhIGFzIGlzLgoKIyMgQnVpbGRpbmcgdGhlIG1vZGVsCgpXZSBhcmUgdXNpbmcgYG1scmAgZm9yIG91ciBtb2RlbGxpbmcgbmVlZHMgYXMgaXQgbWFrZXMgaXQgZWFzeSB0byBwZXJmb3JtIGh5cGVyLXBhcmFtZXRlciB0dW5pbmcgYW5kIGNyb3NzIHZhbGlkYXRpb24gdG8gaW1wcm92ZSB0aGUgcXVhbGl0eSBvZiBvdXIgbW9kZWwuCk1MUiBpcyBhIGZyYW1ld29yayB0aGF0IHVuaWZpZXMgZGlmZmVyZW50IG91dHB1dHMgZnJvbSBkaWZmZXJlbnQgbWFjaGluZSBsZWFybmluZyBhbGdvcml0aG1zIHVuaWZ5aW5nIHRoZSBpbnRlcmZhY2UgYW5kIHByb3ZpZGluZyB0b29scyBmb3IgYW5hbHlzaXMgYW5kIG1vZGVsIG9wdGltaXphdGlvbi4KCldlIHNwbGl0IHRoZSBkYXRhc2V0IHdpdGggYSA3MCAtIDMwIHRyYWluIHRlc3Qgc3BsaXQuIFdlIHZlcmlmeSB0aGVuIHRoYXQgdGhlIHByb3BvcnRpb24gaXMgcm91Z2hseSB0aGUgc2FtZSBnaXZlbiB0aGUgbG93IG51bWJlciBvZiBmcmF1ZHVsZW50IHRyYW5zYWN0aW9ucy4KCmBgYHtyfQpzZXQuc2VlZCg0MikKCnRyYWluLnRlc3QgPC0gc2FtcGxlKDIgIyBlaXRoZXIgb25lIG9yIHR3bwoJLCBucm93KGRmKQoJLCByZXBsYWNlID0gVFJVRQoJLCBwcm9iID0gYygwLjcsIDAuMykpCmRmX3RyYWluID0gZGZbdHJhaW4udGVzdCA9PSAxLF0KZGZfdGVzdCA9IGRmW3RyYWluLnRlc3QgPT0gMixdCgpwcm9wLnRhYmxlKHRhYmxlKGRmX3RyYWluJENsYXNzKSkKcHJvcC50YWJsZSh0YWJsZShkZl90ZXN0JENsYXNzKSkKYGBgCgoKYGBge3J9CiMgV2Ugd2lsbCB1c2UgNzAlIG9mIHRoZSBvYnNlcnZhdGlvbnMgZm9yIHRyYWluaW5nCgojIE1ha2UgdGhlIHRhc2sKY2xhc3MudGFzayA8LSBtYWtlQ2xhc3NpZlRhc2soZGF0YSA9IGRmX3RyYWluLCB0YXJnZXQgPSAiQ2xhc3MiKQoKIyBNYWtlIGEgbGVhcm5lcgojZ2JtLmxlYXJuIDwtIG1ha2VMZWFybmVyKCJjbGFzc2lmLmdibSIpCnhnYm0ubGVhcm4gPC0gbWFrZUxlYXJuZXIoImNsYXNzaWYueGdib29zdCIpCmBgYAoKQSBydW4gd2l0aCB0aGUgZGVmYXVsdCBwYXJhbWV0ZXJzIHdpbGwgcmVuZGVyIHRoZSBmb2xsb3dpbmc6IApUaGF0IGlzIGEgZGVjZW50IHJlc3VsdCB3ZSBvbmx5IGhhdmUgb25lIGZhbHNlIG5lZ2F0aXZlIGkuZS4gYSBmcmF1ZHVsZW50IHRyYW5zYWN0aW9uIHRoYXQgaXMgY29uc2lkZXJlZCBnb29kIGFuZCB3ZSBoYXZlIGZsYWdnZWQgMTQ0IHRyYW5zYWN0aW9ucyBhcyBmcmF1ZCB3aGVuIHRoZXkgd2VyZSBnb29kLgoKV2hpbGUgbm90IHBlcmZlY3Qgd2UgY2FuIHNlZSB0aGF0IEdCTSBjYW4gZGVhbCB3aXRoIHRoZSBpbWJhbGFuY2UgaW4gdGhlIGRhdGEuCgpgYGB7cn0KbW9kZWwgPC0gdHJhaW4obGVhcm5lciA9IHhnYm0ubGVhcm4sIHRhc2sgPSBjbGFzcy50YXNrKQpwZXJmb3JtYW5jZShwcmVkaWN0KG1vZGVsLG5ld2RhdGEgPSBkZl90ZXN0KSwgbWVhc3VyZXMgPSBsaXN0KG1tY2UsIGJlciwgYWNjLCBmbiwgZnAsIGZucixmcHIsIGJhYykpCmBgYAoKYGBge3J9CiNnZXRQYXJhbVNldChnYm0ubGVhcm4pCmdldFBhcmFtU2V0KHhnYm0ubGVhcm4pCmBgYAoKRm9yIHRoZXNlIHRhc2tzIHdlIGFyZSBnb2luZyB0byBkZWZpbmUgYSByYW5kb20gc2VhcmNoLiBUaGlzIHBhcGVyIFtodHRwOi8vd3d3LmptbHIub3JnL3BhcGVycy92b2x1bWUxMy9iZXJnc3RyYTEyYS9iZXJnc3RyYTEyYS5wZGZdIGxheXMgb3V0IHdoeSByYW5kb20gc2VhcmNoIGlzIHJlY29tbWVuZGVkIGluIGdlbmVyYWwgYXMgeW91IHdpbGwgc3BlbmQgbGVzcyB0aW1lIHNlYXJjaGluZyBhbmQgaXQncyBndWFyYW50ZWVkIGFjY29yZGluZyB0byB0aGUgYXV0aG9ycyB0byBnaXZlIGJldHRlciByZXN1bHRzIGdpdmVuIHRoZSBzYW1lIGFtb3VudCBvZiBpdGVyYXRpb25zLiBGb3IgYSBxdWljayBzd2VlcCB3aGVyZSB5b3Ugd2FudCB0byB0ZXN0IGZldyBwYXJhbWV0ZXJzIAoKTUxSIHN1cHBvcnRzIG1vcmUgYWR2YW5jZWQgcGFyYW1ldGVyIHR1bmluZyBmdW5jdGlvbnMgYWx0aG91Z2ggdGhleSBhcmUgbW9yZSBkaWZmaWN1bHQgdG8gcnVuIGluIHBhcmFsbGVsLgoKCmBgYHtyIGV2YWw9RkFMU0UsIGluY2x1ZGU9RkFMU0V9CnBhcmFtX3NldF94Z2JtIDwtIG1ha2VQYXJhbVNldCgKICBtYWtlTnVtZXJpY1BhcmFtKCJldGEiLCBsb3dlciA9IDAuMSwgdXBwZXIgPSAxKSwKICBtYWtlTnVtZXJpY1BhcmFtKCJzdWJzYW1wbGUiLCBsb3dlciA9IDAuOCwgdXBwZXIgPSAxKSwKICBtYWtlTnVtZXJpY1BhcmFtKCJjb2xzYW1wbGVfYnl0cmVlIiwgbG93ZXIgPSAwLjgsIHVwcGVyID0gMSksCiAgbWFrZUludGVnZXJQYXJhbSgibWF4X2RlcHRoIiwgbG93ZXIgPSA1LCB1cHBlciA9IDEyKQopCgojIHBhcmFsbGVsU3RhcnRTb2NrZXQoOCkgIyBmb3IgV2luCiMgcGFyYWxsZWxTdGFydE11bHRpY29yZSg0KSAjIGZvciBVbml4CiNjbHVzdGVyU2V0Uk5HU3RyZWFtKGlzZWVkID0gMTIzNDU2KQp4Z2JtX3R1bmUgPC0gdHVuZVBhcmFtcygKICB4Z2JtLmxlYXJuLAogIGNsYXNzLnRhc2ssCiAgcmVzYW1wbGluZyA9IGNyb3NzX3ZhbCwKICBjb250cm9sID0gY250cmxfZ3JpZCwKICBwYXIuc2V0ID0gcGFyYW1fc2V0X3hnYm0sCiAgbWVhc3VyZXMgPSBsaXN0KG1tY2Usc2V0QWdncmVnYXRpb24obW1jZSwgdHJhaW4ubWVhbiksIGZuciwgc2V0QWdncmVnYXRpb24oZm5yLCB0cmFpbi5tZWFuKSkKKQoKcGFyYWxsZWxTdG9wKCkKCnhnYm1fdHVuZSR4CmBgYAoKT3VyIGJlc3QgcmVzdWx0cyBzZWVtIHRvIGNvbWUgZnJvbSBhIHRyZWUgZGVwdGggb2YgMTIgYSBzdWJzYW1wbGUgb2YgMC45NjcsIGEgY29sc2FtcGxlIG9mIDAuOTU1IGFuZCBhIGxlYXJuaW5nIHJhdGUgb2YgMC43OTQuIAoKVGhlIG5ldyBtb2RlbCB3aXRoIHRoZSBiZXN0IHBhcmFtZXRlcnMgYWNoaWV2ZXMgYSBiZXR0ZXIgcmVzdWx0IGluIGV2ZXJ5IG1ldHJpYyB0aGFuIGJlZm9yZSB0aGUgcmVzdWx0cyBvbiB0aGUgZGZfdGVzdCBkYXRhc2V0IGNhbiBiZSBzZWVuIGJlbG93LiBJdCBjb3VsZCBiZSByZWZpbmVkIHRvIGF2b2lkIGZhbHNlIG5lZ2F0aXZlcyBhbHRvZ2V0aGVyIGhvd2V2ZXIgdGhhdCB3aWxsIGRlcGVuZCBvbiBvdXIgYnVzaW5lc3Mgb2JqZWN0aXZlcyBhbmQgdGhpcyByZXN1bHRzIHNlZW0gYSBnb29kIGNvbXByb21pc2UuCgpgYGB7cn0KbHJuX2Jlc3QgPC0gc2V0SHlwZXJQYXJzKHhnYm0ubGVhcm4sIHBhci52YWxzID0geGdibV90dW5lJHgpCgptb2RlbF9iZXN0IDwtIHRyYWluKGxybl9iZXN0LGNsYXNzLnRhc2spCnBlcmZvcm1hbmNlKHByZWRpY3QobW9kZWxfYmVzdCxuZXdkYXRhID0gZGZfdGVzdCksIG1lYXN1cmVzID0gbGlzdChtbWNlLCBiZXIsIGFjYywgZm4sIGZwLCBmbnIsZnByLCBiYWMpKQoKYGBgCgpOb3cgdGhhdCB3ZSBoYXZlIG91ciBtb2RlbCBob3cgY2FuIHdlIHVzZSBpdCB0byBtYWtlIHByZWRpY3Rpb25zPyBCZWxvdyB3ZSBzZWUgaG93IGEgc2V0IG9mIHBhcmFtZXRlcnMgY2FsbGVkIGV4YW1wbGUgY2FuIGJlIHBhc3NlZCB0byBvdXIgbW9kZWwuIEluIHRoaXMgY2FzZSB3ZSBhcmUgcGFzc2luZyBhIDEgb2JzZXJ2YXRpb24gZGF0YWZyYW1lLgoKYGBge3J9CmV4YW1wbGUgPC0gZGZfdGVzdFsiNjMzNSIsMToobmNvbChkZl90ZXN0KSldCmV4YW1wbGUKCnJlcyA8LSBwcmVkaWN0KG1vZGVsX2Jlc3QsIG5ld2RhdGEgPSBleGFtcGxlKQpyZXMgPC0gcmVzJGRhdGEkcmVzcG9uc2UgJT4lIGFzLmNoYXJhY3RlcigpICU+JSBhcy5udW1lcmljKCkKcmVzCmBgYAoKVGhlIHJlc3VsdCBpcyAxIGZvciBmcmF1ZCB3aGljaCBpcyBjb3JyZWN0IGluIHRoaXMgY2FzZS4gSG93ZXZlciBiZWluZyBhYmxlIHRvIHByZWRpY3QgZnJhdWQgb24gb2xkIGRhdGEgaXMgbm90IHZlcnkgdXNlZnVsLCBmb3IgdGhpcyBtb2RlbCB0byBhZGQgcmVhbCB2YWx1ZSB3ZSBuZWVkIHRvIGJlIGFibGUgdG8gY29ubmVjdCBpdCB0byBvdXIgZXhpc3Rpbmcgc2VydmljZXMgc28gd2UgY2FuIG9idGFpbiBmYXN0IHJlc3VsdHMgYW5kIGNhbmNlbCB0aGUgZnJhdWR1bGVudCB0cmFuc2FjdGlvbnMgZm9yIG91ciBjbGllbnRzLgoKVG8gZG8gdGhhdCB3ZSBuZWVkIHRvIHNhdmUgdGhlIHJlc3VsdHMgb24gYSBgcmRzYCBmaWxlIChvciB3ZSBwaWNrbGUgdGhlIG1vZGVsIGluIFB5dGhvbiBsaW5nbyk6CgpgYGB7cn0Kc2F2ZVJEUyhtb2RlbF9iZXN0LCIuL291dHB1dC9tb2RlbF9iZXN0LnJkcyIpCmBgYAoKClNlZSBNb3JlIG9uIFtQYXJ0IDI6IERlcGxveWluZyBhIG1vZGVsXShQYXJ0XzIubmIuaHRtbCkKCg==