Medical Student Enrollment Forecasting.nb

16 minute read

Random Forest + Boosted Linear Ensemble
Student enrollment forecasting graphic

Student enrollment forecasting graphic

 

Business Challenge

Can we forecast medical student enrollments using advertising data from web-based platforms?

This is the question I tried to answer using Lone Star Medical Career College’s digital advertising campaign, spending, and student enrollment data to forecast enrollments for the coming year. As I started to develop KPI charts for Lone Star Medical Career College, it became apparent to me that this might be a big opportunity to apply the predictive modeling workflow I had just learned in my ML class.

Why student enrollments? For a career college, this is the main return on investment (ROI) figure. Technically, the return for all the time and resources that go into digital marketing can be measured in many different ways: demographic exposure, increases in online followers, etc. In this case, I wanted any work I do to potentially have a direct impact on the company’s bottom line. This is a common tactic, the researchers on Garter.com tell me, for any company piloting a data mining initiative. The following notebook highlights my first attempt at this business challenge.

Note: Due to delays in the big data approach, I decided to go with the data supplied on their KPI spreadsheets for preliminary analysis. Future development will be a big data project with actual hourly social media advertising data. Also, the name of the college was changed to ensure company privacy.

# Libraries
library(caret)
library(skimr)
library(corrplot)

Data Challenge

To forecast student enrollments, I will use the medical school’s monthly historical data and their digital marketing company’s monthly KPI reports for campaign spending. At first, I incorporated other marketing-based dependent variables into the data such as lead counts and social media platform (Google Ads vs. Facebook Ads), but initial model performances seemed rather low. After some refinement and visual exploration using Tableau, I settled for the following dependent variables: campaign type and spending.

Imported Data

# Import and clean data
data <- read.csv("C:/Users/george/Documents/Digital Marketing Data/Triggers/spendingAndEnrollments.csv")
dataRGV <- data
dataRGV

Skimmed Data

Using skimr allows an at-a-glance view of the imported data. Campaign is a factor variable and spending is numeric. Before visualizing or processing the campaign variable, I will have to convert it to to a numeric dummy variable for better performance. I also note that there are only 51 instances of this data, so how I decide to partition the data will have a bigger impact on performance than usual.

# Split data
x <- subset(dataRGV, select = -Enrollments)
y <- subset(dataRGV, select = Enrollments)

# Data summary
skimX <- skim(x)
skimY <- skim(y)
skimX
-- Data Summary ------------------------
                           Values
Name                       x     
Number of rows             51    
Number of columns          2     
_______________________          
Column type frequency:           
  factor                   1     
  numeric                  1     
________________________         
Group variables            None  

-- Variable type: factor ----------------------------------------------------------------------------
# A tibble: 1 x 6
  skim_variable n_missing complete_rate ordered n_unique top_counts                        
* <chr>             <int>         <dbl> <lgl>      <int> <chr>                             
1 Campaign              0             1 FALSE          5 LVN: 11, Med: 11, Nur: 11, Pha: 11

-- Variable type: numeric ---------------------------------------------------------------------------
# A tibble: 1 x 11
  skim_variable n_missing complete_rate  mean    sd    p0   p25   p50   p75  p100 hist 
* <chr>             <int>         <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
1 Spending              0             1 1377.  505.  362.  958. 1232. 1778. 2270. ▁▇▅▅▅
skimY
-- Data Summary ------------------------
                           Values
Name                       y     
Number of rows             51    
Number of columns          1     
_______________________          
Column type frequency:           
  numeric                  1     
________________________         
Group variables            None  

-- Variable type: numeric ---------------------------------------------------------------------------
# A tibble: 1 x 11
  skim_variable n_missing complete_rate  mean    sd    p0   p25   p50   p75  p100 hist 
* <chr>             <int>         <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
1 Enrollments           0             1  9.20  8.03     0     3     8    13    31 ▇▅▂▁▂
# Data partition
seed <- 123
set.seed(seed)
dataPart <- createDataPartition(y$Enrollments,
                              p = .70,
                              list = FALSE)
xTrain <- x[dataPart,]
yTrain <- y[dataPart,]

xTest <- x[-dataPart,]
yTest <- y[-dataPart,]

Training Data Visualization and Correlation

For a visual component to the training data, I use the standard boxplot and histogram charts. I also plot the correlation between variables. At this point I note that no correlation screening seems necessary for this data, and the only outliers are in the dependent variable data.

# Create numeric set of x variables
xTrainNum <- as.data.frame(sapply(xTrain, as.numeric))

# Boxplot of predictors
boxplot(xTrainNum)

boxplot(yTrain)


# Histograms
hist(xTrainNum$Campaign)

hist(xTrainNum$Spending)

hist(yTrain)


# Correlation plots and correlation ceiling
correlations <- cor(xTrainNum)
corrplot(correlations, method = "number", order = "hclust")

ML Algorithm Training

Pre-processing

I preprocessed using the standard center/scaling and YeoJohnson to normalize the data, and spatialSign for outliers (which I don’t think is necessary, but I usually add it anyway as it might boost performance a bit). I also used 10-fold repeated cross validation for resampling. In addition, I used dummy variables to account for the Campaign factor variable, and tuned all models.

Models and Tuned Performance

Using the caret workflow, I trained the data on the following models:

  • Boosted Linear Regression (BstLm)
  • Elastic Net Regression (enet)
  • Generalized Linear Regression (glmnet)
  • Least Angle Regression (lars)
  • Random Forest (rf)

Of the models I trained, ‘rf’ random forest seemed to have the best performance with an RMSE = 4.99 and R2 = 0.74.

# Model set up
dmy <- dummyVars(" ~ .", data = xTrain, fullRank = T)
xTrainDummy <- data.frame(predict(dmy, newdata = xTrain))

set.seed(seed)
ctrl <- trainControl(method = "repeatedcv", number = 10, repeats = 10)


# Predictive modeling w/ built-in feature selection
gridBstLm <- expand.grid(mstop = seq(8, 12, by = 1), nu = 0.5)
set.seed(seed)
modelBstLm <- train(x = xTrainDummy, y = yTrain, 
                method = "BstLm", 
                preProc = c("center", "scale", "YeoJohnson", "spatialSign"),
                tuneGrid = gridBstLm,
                trControl = ctrl)
modelBstLm # RMSE = 5.36
Boosted Linear Model 

37 samples
 5 predictor

Pre-processing: centered (5), scaled (5), Yeo-Johnson transformation (1), spatial
 sign transformation (5) 
Resampling: Cross-Validated (10 fold, repeated 10 times) 
Summary of sample sizes: 33, 33, 34, 33, 33, 34, ... 
Resampling results across tuning parameters:

  mstop  RMSE      Rsquared   MAE     
   8     5.473832  0.7172550  4.423317
   9     5.423629  0.7146034  4.332640
  10     5.361737  0.7182865  4.291986
  11     5.421260  0.7097770  4.344338
  12     5.441640  0.7093973  4.332588

Tuning parameter 'nu' was held constant at a value of 0.5
RMSE was used to select the optimal model using the smallest value.
The final values used for the model were mstop = 10 and nu = 0.5.
gridEnet <- expand.grid(fraction = seq(0.6, 0.8, by = 0.1), lambda = 0)
set.seed(seed)
modelEnet <- train(x = xTrainDummy, y = yTrain, 
                method = "enet", 
                preProc = c("center", "scale", "YeoJohnson", "spatialSign"),
                tuneGrid = gridEnet,
                trControl = ctrl)
modelEnet # RMSE = 5.15
Elasticnet 

37 samples
 5 predictor

Pre-processing: centered (5), scaled (5), Yeo-Johnson transformation (1), spatial
 sign transformation (5) 
Resampling: Cross-Validated (10 fold, repeated 10 times) 
Summary of sample sizes: 33, 33, 34, 33, 33, 34, ... 
Resampling results across tuning parameters:

  fraction  RMSE      Rsquared   MAE     
  0.6       5.145523  0.7253282  4.015712
  0.7       5.145162  0.7193330  3.940605
  0.8       5.159086  0.7181921  3.933155

Tuning parameter 'lambda' was held constant at a value of 0
RMSE was used to select the optimal model using the smallest value.
The final values used for the model were fraction = 0.7 and lambda = 0.
gridGlmNet <- expand.grid(alpha = c(0.9, 1, 1.1), lambda = c(0.6, 0.7, 0.8))
set.seed(seed)
modelGlmNet <- train(x = xTrainDummy, y = yTrain, 
                method = "glmnet",
                preProc = c("center", "scale", "YeoJohnson", "spatialSign"),
                tuneGrid = gridGlmNet,
                trControl = ctrl)
alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1alpha >1; set to 1
modelGlmNet # RMSE = 5.17
glmnet 

37 samples
 5 predictor

Pre-processing: centered (5), scaled (5), Yeo-Johnson transformation (1), spatial sign transformation (5) 
Resampling: Cross-Validated (10 fold, repeated 10 times) 
Summary of sample sizes: 33, 33, 34, 33, 33, 34, ... 
Resampling results across tuning parameters:

  alpha  lambda  RMSE      Rsquared   MAE     
  0.9    0.6     5.179595  0.7158963  3.969675
  0.9    0.7     5.178803  0.7172605  3.986190
  0.9    0.8     5.178822  0.7197332  4.018424
  1.0    0.6     5.175057  0.7169748  3.971990
  1.0    0.7     5.172897  0.7193271  3.997127
  1.0    0.8     5.172940  0.7228559  4.039927
  1.1    0.6     5.175057  0.7169748  3.971990
  1.1    0.7     5.172897  0.7193271  3.997127
  1.1    0.8     5.172940  0.7228559  4.039927

RMSE was used to select the optimal model using the smallest value.
The final values used for the model were alpha = 1 and lambda = 0.7.
gridLars <- expand.grid(fraction = c(0.65, 0.7, 0.75))
set.seed(seed)
modelLars <- train(x = xTrainDummy, y = yTrain, 
                method = "lars", 
                preProc = c("center", "scale", "YeoJohnson", "spatialSign"),
                tuneGrid = gridLars,
                trControl = ctrl)
modelLars # RMSE = 5.14
Least Angle Regression 

37 samples
 5 predictor

Pre-processing: centered (5), scaled (5), Yeo-Johnson transformation (1), spatial sign transformation (5) 
Resampling: Cross-Validated (10 fold, repeated 10 times) 
Summary of sample sizes: 33, 33, 34, 33, 33, 34, ... 
Resampling results across tuning parameters:

  fraction  RMSE      Rsquared   MAE     
  0.65      5.143184  0.7214069  3.962712
  0.70      5.145162  0.7193330  3.940605
  0.75      5.150836  0.7185754  3.926442

RMSE was used to select the optimal model using the smallest value.
The final value used for the model was fraction = 0.65.
gridRF <- expand.grid(mtry = c(1.5, 2, 2.5))
set.seed(seed)
modelRF <- train(x = xTrainDummy, y = yTrain, 
                method = "rf", 
                preProc = c("center", "scale", "YeoJohnson", "spatialSign"),
                tuneGrid = gridRF,
                trControl = ctrl)
modelRF # RMSE = 4.99, R^2 = 0.74
Random Forest 

37 samples
 5 predictor

Pre-processing: centered (5), scaled (5), Yeo-Johnson transformation (1), spatial sign transformation (5) 
Resampling: Cross-Validated (10 fold, repeated 10 times) 
Summary of sample sizes: 33, 33, 34, 33, 33, 34, ... 
Resampling results across tuning parameters:

  mtry  RMSE      Rsquared   MAE     
  1.5   4.999638  0.7365701  3.699015
  2.0   4.993346  0.7359774  3.713026
  2.5   4.994010  0.7383909  3.702070

RMSE was used to select the optimal model using the smallest value.
The final value used for the model was mtry = 2.

Model Performance Comparison Visualization

The below tables and graphs show that though the performance is similar across the models, ‘rf’ random forest performs a little better on the data than the other models. In addition, the largest statistical difference lies between ‘rf’ random forest and ‘BstLm’ boosted linear regression, so I may want to use them together if I decide to train a model ensemble.

# Compare model performances
modelResults <- resamples(list(BstLm = modelBstLm, Enet = modelEnet, GlmNet = modelGlmNet,
                               Lars = modelLars, RF = modelRF))
summary(modelResults)

Call:
summary.resamples(object = modelResults)

Models: BstLm, Enet, GlmNet, Lars, RF 
Number of resamples: 100 

MAE 
            Min.  1st Qu.   Median     Mean  3rd Qu.     Max. NA's
BstLm  1.0604282 2.850993 4.017808 4.291986 5.782943 9.492177    0
Enet   0.3811782 2.405743 3.534180 3.940605 5.562529 8.729665    0
GlmNet 0.5022129 2.424687 3.721210 3.997127 5.564189 8.642130    0
Lars   0.4126387 2.473788 3.680178 3.962712 5.571044 8.506586    0
RF     0.5466667 1.821698 3.620551 3.713026 5.237585 9.404392    0

RMSE 
            Min.  1st Qu.   Median     Mean  3rd Qu.     Max. NA's
BstLm  1.0940970 3.326571 4.654847 5.361737 7.719512 11.44774    0
Enet   0.3853941 2.882951 4.467947 5.145162 7.422270 11.68322    0
GlmNet 0.5091294 3.038352 4.510618 5.172897 7.321011 11.65059    0
Lars   0.4411069 3.004878 4.494555 5.143184 7.108701 11.72801    0
RF     0.5676295 2.278917 4.603319 4.993346 7.753244 11.67767    0

Rsquared 
               Min.   1st Qu.    Median      Mean   3rd Qu.      Max. NA's
BstLm  1.863982e-04 0.5673405 0.8226835 0.7182865 0.9379406 0.9999987    0
Enet   1.488194e-04 0.5670039 0.8265974 0.7193330 0.9248793 0.9999990    0
GlmNet 1.214535e-04 0.5735208 0.8275697 0.7193271 0.9297692 0.9999997    0
Lars   5.876646e-05 0.5780867 0.8314518 0.7214069 0.9291269 0.9999995    0
RF     6.060585e-03 0.5901719 0.8272488 0.7359774 0.9576658 0.9999867    0
dotplot(modelResults)


# Check differences
modelDiff <- diff(modelResults)
summary(modelDiff)

Call:
summary.diff.resamples(object = modelDiff)

p-value adjustment: bonferroni 
Upper diagonal: estimates of the difference
Lower diagonal: p-value for H0: difference = 0

MAE 
       BstLm     Enet      GlmNet    Lars      RF      
BstLm             0.35138   0.29486   0.32927   0.57896
Enet   4.171e-11           -0.05652  -0.02211   0.22758
GlmNet 5.041e-08 0.0028759            0.03442   0.28410
Lars   4.624e-09 1.0000000 0.0001817            0.24969
RF     0.0006684 1.0000000 0.4586034 0.8073614         

RMSE 
       BstLm     Enet      GlmNet    Lars      RF       
BstLm             0.216575  0.188840  0.218553  0.368391
Enet   0.0005908           -0.027735  0.001978  0.151816
GlmNet 0.0095493 0.3060463            0.029713  0.179551
Lars   0.0030252 1.0000000 0.0079327            0.149838
RF     0.2641020 1.0000000 1.0000000 1.0000000          

Rsquared 
       BstLm   Enet       GlmNet     Lars       RF        
BstLm          -1.047e-03 -1.041e-03 -3.120e-03 -1.769e-02
Enet   1.00000             5.877e-06 -2.074e-03 -1.664e-02
GlmNet 1.00000 1.00000               -2.080e-03 -1.665e-02
Lars   1.00000 1.00000    0.06348               -1.457e-02
RF     1.00000 1.00000    1.00000    1.00000              
dotplot(modelDiff)

Finalized Model Performance on Test Data

The final RMSE and R2 for the ‘rf’ random forest model on the test data:

# Transform test data
dmyTest <- dummyVars(" ~ .", data = xTest, fullRank = T)
xTestDummy <- data.frame(predict(dmyTest, newdata = xTest))

# Evaluate on test data
predictRF <- predict(modelRF, newdata = xTestDummy)
RMSE(predictRF, yTest)
[1] 6.612456
R2(predictRF, yTest)
[1] 0.4893805

Final Model Test Details

The final ‘rf’ model returned disappointing results for the test data. Only 49% of the variance can be explained by the model which returned an RMSE = 6.61, a lot higher of an error than with the training set. Final model details are given:

# Finalize Model
library(randomForest)
randomForest 4.6-14
Type rfNews() to see new features/changes/bug fixes.

Attaching package: 㤼㸱randomForest㤼㸲

The following object is masked from 㤼㸱package:ggplot2㤼㸲:

    margin
set.seed(seed)
preProcParams <- preProcess(xTrainDummy, method = c("center", "scale", "YeoJohnson", "spatialSign"))
xTrainTrans <- predict(preProcParams, xTrainDummy)
finalModel <- randomForest(x = xTrainTrans, y = yTrain, mstop = 10)
finalModel

Call:
 randomForest(x = xTrainTrans, y = yTrain, mstop = 10) 
               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 1

          Mean of squared residuals: 31.06351
                    % Var explained: 46.7
summary(finalModel)
                Length Class  Mode     
call              4    -none- call     
type              1    -none- character
predicted        37    -none- numeric  
mse             500    -none- numeric  
rsq             500    -none- numeric  
oob.times        37    -none- numeric  
importance        5    -none- numeric  
importanceSD      0    -none- NULL     
localImportance   0    -none- NULL     
proximity         0    -none- NULL     
ntree             1    -none- numeric  
mtry              1    -none- numeric  
forest           11    -none- list     
coefs             0    -none- NULL     
y                37    -none- numeric  
test              0    -none- NULL     
inbag             0    -none- NULL     
# Evaluate on test data
xTestNum <- as.data.frame(sapply(xTest, as.numeric))
set.seed(seed)
xTestTrans <- predict(preProcParams, xTestDummy)
finalModelPredict <- predict(finalModel, newdata = xTestTrans, mstop = 10)

# Calculate performance
finalModelRMSE <- RMSE(finalModelPredict, yTest)
finalModelR2 <- R2(finalModelPredict, yTest)
finalModelRMSE
[1] 6.625432

At this point, it seems the model may be overfit. For now, let’s proceed to trying an ensemble of the ‘rf’ random forest and ‘BstLm’ boosted linear models.

Train and Evaluate ‘rf’ + ‘BstLm’ Model Ensemble

The ‘rf’ + ‘BstLm’ model ensemble performed much better on the training data than either model alone. However, the ensemble resulted in a slight decrease in RMSE performance and a slight increase in R2 performance on the test data.

library(caretEnsemble)

Attaching package: 㤼㸱caretEnsemble㤼㸲

The following object is masked from 㤼㸱package:ggplot2㤼㸲:

    autoplot
# Train a list of models with caretList()
listOfModels <- c('rf', 'BstLm')
set.seed(seed)
modelList <- caretList(x = xTrainDummy, y = yTrain, 
                    trControl = ctrl,
                    preProc = c("center", "scale", "YeoJohnson", "spatialSign"), 
                    methodList = listOfModels)
trControl$savePredictions not 'all' or 'final'.  Setting to 'final' so we can ensemble the models.indexes not defined in trControl.  Attempting to set them ourselves, so each model in the ensemble will have the same resampling indexes.
# Model results and correlation
results <- resamples(modelList)
summary(results)

Call:
summary.resamples(object = results)

Models: rf, BstLm 
Number of resamples: 100 

MAE 
           Min.  1st Qu.   Median    Mean  3rd Qu.     Max. NA's
rf    0.6956250 1.714279 3.508950 3.75202 5.066671 10.76811    0
BstLm 0.6308284 2.613128 3.969049 4.33590 5.610788 11.57693    0

RMSE 
           Min.  1st Qu.   Median     Mean  3rd Qu.     Max. NA's
rf    0.7554055 2.176715 4.471739 4.980845 7.477950 12.63715    0
BstLm 0.6886622 3.335793 4.804094 5.316516 7.318563 12.37133    0

Rsquared 
              Min.   1st Qu.    Median      Mean   3rd Qu.     Max. NA's
rf    1.398705e-03 0.6367162 0.8773240 0.7373097 0.9651239 0.999880    0
BstLm 8.916097e-05 0.5744188 0.7831661 0.7132427 0.9424384 0.998526    0
dotplot(results)

modelCor(results)
             rf     BstLm
rf    1.0000000 0.8122617
BstLm 0.8122617 1.0000000
# Combine the models with caretEnsemble()
set.seed(seed)
modelEnsemble <- caretEnsemble(modelList, metric = "RMSE",
                              trControl = trainControl(number = 2))

summary(modelEnsemble) # RMSE = 5.0463
The following models were ensembled: rf, BstLm 
They were weighted: 
0.4024 0.5448 0.3882
The resulting RMSE is: 5.691
The fit for each individual model on the RMSE is: 
# Evaluate on test data
predictEnsemble <- predict(modelEnsemble, newdata = xTestDummy)
RMSE(predictEnsemble, yTest)
[1] 6.465112
R2(predictEnsemble, yTest)
[1] 0.5127668

Since overall performance is comparable, I’ll go ahead and roll with it for the enrollment forecasting.

Data Solution

Final Model Prediction: Forecasted Enrollments

To forecast future student enrollments, I used the following December 2019 spending figures:

  • LVN: $1,271.86
  • Med Asst: $2,568.00
  • Pharmacy: $1,015.64
  • Nurse Aide: $939.75
  • Med Asst: $1,688.54

Let’s assume the following spending increases by Lone Star Medical Career College:

  • Dec + 10%
  • Dec + 15%
  • Dec + 20%

Based on the different campaign categories and these increases in spending, the third column lists the forecasted student enrollments.

# Import and clean data
predData <- read.csv("C:/Users/george/Documents/Digital Marketing Data/Triggers/predictedSpendingAndEnrollments.csv")

# Transform forecast data
dmyPredData <- dummyVars(" ~ .", data = predData, fullRank = T)
predDummy <- data.frame(predict(dmyPredData, newdata = predData))

# Predict forecast data
predEnrollments <- data.frame(predict(modelEnsemble, newdata = predDummy))
predExport <- data.frame(c(predData,predEnrollments))
predExport
#write.csv(predExport, "C:/Users/george/Documents/Digital Marketing Data/Triggers/predictedExport.csv", row.names = FALSE)

Business Solution

Overall, the ‘rf’+‘BstLm’ ensemble model indicates that incremental increases in spending should have a positive effect on the number of overall medical student enrollments. However, due to low model performance, these predictions are not very conclusive. Future projects should provide higher quality data and better modeling parameters.

Since this is the first kind of applied data mining project for both the medical school and their digital marketing company, the transaction channel between company ad spending and student enrollment counts is not yet automated into a database, and it is not clear how either company communicates this type of ROI with each other. In other words, we can not fully account for the amount of student enrollments that are solely attributed to ad-generated leads and spending. Unfortunately, this is a major issue when attempting to forecast future enrollments. Due to this fact, the model developed appears to be overfit and does not handle new data as well as hoped.

Future developments may include more grainular, hourly data sourced from the online platforms themselves and perhaps developing a less overfit model. In addition, maybe a new strategy for seeing ad-generated leads through to enrollments can be optimized for much better marketing funnel business intelligence performance. This project demonstrates the importance of streamlining business processes for optimized intelligence.

LS0tDQp0aXRsZTogIlJhbmRvbSBGb3Jlc3QgKyBCb29zdGVkIExpbmVhciBFbnNlbWJsZSINCmRhdGU6IDIwMjAtMDEtMTYNCnRhZ3M6IFttYWNoaW5lIGxlYXJuaW5nLCBmb3JlY2FzdGluZ10NCmhlYWRlcjoNCiMgIGltYWdlOiAiL2ltYWdlcy9lbnJvbGxtZW50Rm9yZWNhc3RQaWMucG5nIg0KZXhjZXJwdDogIk1hY2hpbmUgTGVhcm5pbmcsIEZvcmVjYXN0aW5nIg0Kb3V0cHV0OiANCiAgaHRtbF9ub3RlYm9vazoNCiAgICBjb2RlX2ZvbGRpbmc6IGhpZGUNCi0tLQ0KPGNlbnRlcj4NCg0KIVsqU3R1ZGVudCBlbnJvbGxtZW50IGZvcmVjYXN0aW5nIGdyYXBoaWMqXShDOi9Vc2Vycy9nZW9yZ2UvRG9jdW1lbnRzL1IgQXNzaWdubWVudHMvRUJCIEFORCBGTE9XL0dlb3JnZUFhcm9uRy5naXRodWIuaW8vaW1hZ2VzL2Vucm9sbG1lbnRGb3JlY2FzdFBpYy5wbmcpDQoNCjwvY2VudGVyPg0KDQombmJzcDsNCg0KIyBCdXNpbmVzcyBDaGFsbGVuZ2UNCg0KPiBDYW4gd2UgZm9yZWNhc3QgbWVkaWNhbCBzdHVkZW50IGVucm9sbG1lbnRzIHVzaW5nIGFkdmVydGlzaW5nIGRhdGEgZnJvbSB3ZWItYmFzZWQgcGxhdGZvcm1zPw0KDQpUaGlzIGlzIHRoZSBxdWVzdGlvbiBJIHRyaWVkIHRvIGFuc3dlciB1c2luZyBMb25lIFN0YXIgTWVkaWNhbCBDYXJlZXIgQ29sbGVnZSdzIGRpZ2l0YWwgYWR2ZXJ0aXNpbmcgY2FtcGFpZ24sIHNwZW5kaW5nLCBhbmQgc3R1ZGVudCBlbnJvbGxtZW50IGRhdGEgdG8gZm9yZWNhc3QgZW5yb2xsbWVudHMgZm9yIHRoZSBjb21pbmcgeWVhci4gIEFzIEkgc3RhcnRlZCB0byBkZXZlbG9wIFtLUEkgY2hhcnRzIGZvciBMb25lIFN0YXIgTWVkaWNhbCBDYXJlZXIgQ29sbGVnZV0oaHR0cHM6Ly9wdWJsaWMudGFibGVhdS5jb20vcHJvZmlsZS9nZW9yZ2UuZ2FyY2lhMzk2MyMhL3ZpemhvbWUvTG9uZVN0YXJNZWRpY2FsQ2FyZWVyQ29sbGVnZS9Mb25lU3Rhck1lZGljYWxDYXJlZXJDb2xsZWdlKSwgaXQgYmVjYW1lIGFwcGFyZW50IHRvIG1lIHRoYXQgdGhpcyBtaWdodCBiZSBhIGJpZyBvcHBvcnR1bml0eSB0byBhcHBseSB0aGUgcHJlZGljdGl2ZSBtb2RlbGluZyB3b3JrZmxvdyBJIGhhZCBqdXN0IGxlYXJuZWQgaW4gbXkgTUwgY2xhc3MuDQoNCldoeSBzdHVkZW50IGVucm9sbG1lbnRzPyAgRm9yIGEgY2FyZWVyIGNvbGxlZ2UsIHRoaXMgaXMgdGhlIG1haW4gcmV0dXJuIG9uIGludmVzdG1lbnQgKFJPSSkgZmlndXJlLiAgVGVjaG5pY2FsbHksIHRoZSByZXR1cm4gZm9yIGFsbCB0aGUgdGltZSBhbmQgcmVzb3VyY2VzIHRoYXQgZ28gaW50byBkaWdpdGFsIG1hcmtldGluZyBjYW4gYmUgbWVhc3VyZWQgaW4gbWFueSBkaWZmZXJlbnQgd2F5czogZGVtb2dyYXBoaWMgZXhwb3N1cmUsIGluY3JlYXNlcyBpbiBvbmxpbmUgZm9sbG93ZXJzLCBldGMuICBJbiB0aGlzIGNhc2UsIEkgd2FudGVkIGFueSB3b3JrIEkgZG8gdG8gcG90ZW50aWFsbHkgaGF2ZSBhIGRpcmVjdCBpbXBhY3Qgb24gdGhlIGNvbXBhbnkncyBib3R0b20gbGluZS4gIFRoaXMgaXMgYSBjb21tb24gdGFjdGljLCBbdGhlIHJlc2VhcmNoZXJzIG9uIEdhcnRlci5jb20gdGVsbCBtZV0oaHR0cHM6Ly93d3cuZ2FydG5lci5jb20vZW4pLCBmb3IgYW55IGNvbXBhbnkgcGlsb3RpbmcgYSBkYXRhIG1pbmluZyBpbml0aWF0aXZlLiAgVGhlIGZvbGxvd2luZyBub3RlYm9vayBoaWdobGlnaHRzIG15IGZpcnN0IGF0dGVtcHQgYXQgdGhpcyBidXNpbmVzcyBjaGFsbGVuZ2UuDQoNCipOb3RlOiogIER1ZSB0byBkZWxheXMgaW4gdGhlIGJpZyBkYXRhIGFwcHJvYWNoLCBJIGRlY2lkZWQgdG8gZ28gd2l0aCB0aGUgZGF0YSBzdXBwbGllZCBvbiB0aGVpciBLUEkgc3ByZWFkc2hlZXRzIGZvciBwcmVsaW1pbmFyeSBhbmFseXNpcy4gIEZ1dHVyZSBkZXZlbG9wbWVudCB3aWxsIGJlIGEgYmlnIGRhdGEgcHJvamVjdCB3aXRoIGFjdHVhbCBob3VybHkgc29jaWFsIG1lZGlhIGFkdmVydGlzaW5nIGRhdGEuICBBbHNvLCB0aGUgbmFtZSBvZiB0aGUgY29sbGVnZSB3YXMgY2hhbmdlZCB0byBlbnN1cmUgY29tcGFueSBwcml2YWN5Lg0KDQpgYGB7ciBtZXNzYWdlID0gIEZBTFNFfQ0KIyBMaWJyYXJpZXMNCmxpYnJhcnkoY2FyZXQpDQpsaWJyYXJ5KHNraW1yKQ0KbGlicmFyeShjb3JycGxvdCkNCmBgYA0KDQojIERhdGEgQ2hhbGxlbmdlDQoNClRvIGZvcmVjYXN0IHN0dWRlbnQgZW5yb2xsbWVudHMsIEkgd2lsbCB1c2UgdGhlIG1lZGljYWwgc2Nob29sJ3MgbW9udGhseSBoaXN0b3JpY2FsIGRhdGEgYW5kIHRoZWlyIGRpZ2l0YWwgbWFya2V0aW5nIGNvbXBhbnkncyBtb250aGx5IEtQSSByZXBvcnRzIGZvciBjYW1wYWlnbiBzcGVuZGluZy4gIEF0IGZpcnN0LCBJIGluY29ycG9yYXRlZCBvdGhlciBtYXJrZXRpbmctYmFzZWQgZGVwZW5kZW50IHZhcmlhYmxlcyBpbnRvIHRoZSBkYXRhIHN1Y2ggYXMgbGVhZCBjb3VudHMgYW5kIHNvY2lhbCBtZWRpYSBwbGF0Zm9ybSAoR29vZ2xlIEFkcyB2cy4gRmFjZWJvb2sgQWRzKSwgYnV0IGluaXRpYWwgbW9kZWwgcGVyZm9ybWFuY2VzIHNlZW1lZCByYXRoZXIgbG93LiAgQWZ0ZXIgc29tZSByZWZpbmVtZW50IGFuZCB2aXN1YWwgZXhwbG9yYXRpb24gdXNpbmcgKlRhYmxlYXUqLCBJIHNldHRsZWQgZm9yIHRoZSBmb2xsb3dpbmcgZGVwZW5kZW50IHZhcmlhYmxlczogY2FtcGFpZ24gdHlwZSBhbmQgc3BlbmRpbmcuDQoNCiMjIyBJbXBvcnRlZCBEYXRhDQpgYGB7cn0NCiMgSW1wb3J0IGFuZCBjbGVhbiBkYXRhDQpkYXRhIDwtIHJlYWQuY3N2KCJDOi9Vc2Vycy9nZW9yZ2UvRG9jdW1lbnRzL0RpZ2l0YWwgTWFya2V0aW5nIERhdGEvVHJpZ2dlcnMvc3BlbmRpbmdBbmRFbnJvbGxtZW50cy5jc3YiKQ0KZGF0YVJHViA8LSBkYXRhDQpkYXRhUkdWDQpgYGANCg0KIyMjIFNraW1tZWQgRGF0YQ0KDQpVc2luZyAqc2tpbXIqIGFsbG93cyBhbiBhdC1hLWdsYW5jZSB2aWV3IG9mIHRoZSBpbXBvcnRlZCBkYXRhLiAgQ2FtcGFpZ24gaXMgYSBmYWN0b3IgdmFyaWFibGUgYW5kIHNwZW5kaW5nIGlzIG51bWVyaWMuICBCZWZvcmUgdmlzdWFsaXppbmcgb3IgcHJvY2Vzc2luZyB0aGUgY2FtcGFpZ24gdmFyaWFibGUsIEkgd2lsbCBoYXZlIHRvIGNvbnZlcnQgaXQgdG8gdG8gYSBudW1lcmljIGR1bW15IHZhcmlhYmxlIGZvciBiZXR0ZXIgcGVyZm9ybWFuY2UuICBJIGFsc28gbm90ZSB0aGF0IHRoZXJlIGFyZSBvbmx5IDUxIGluc3RhbmNlcyBvZiB0aGlzIGRhdGEsIHNvIGhvdyBJIGRlY2lkZSB0byBwYXJ0aXRpb24gdGhlIGRhdGEgd2lsbCBoYXZlIGEgYmlnZ2VyIGltcGFjdCBvbiBwZXJmb3JtYW5jZSB0aGFuIHVzdWFsLg0KYGBge3J9DQojIFNwbGl0IGRhdGENCnggPC0gc3Vic2V0KGRhdGFSR1YsIHNlbGVjdCA9IC1FbnJvbGxtZW50cykNCnkgPC0gc3Vic2V0KGRhdGFSR1YsIHNlbGVjdCA9IEVucm9sbG1lbnRzKQ0KDQojIERhdGEgc3VtbWFyeQ0Kc2tpbVggPC0gc2tpbSh4KQ0Kc2tpbVkgPC0gc2tpbSh5KQ0Kc2tpbVgNCnNraW1ZDQoNCiMgRGF0YSBwYXJ0aXRpb24NCnNlZWQgPC0gMTIzDQpzZXQuc2VlZChzZWVkKQ0KZGF0YVBhcnQgPC0gY3JlYXRlRGF0YVBhcnRpdGlvbih5JEVucm9sbG1lbnRzLA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgcCA9IC43MCwNCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGxpc3QgPSBGQUxTRSkNCnhUcmFpbiA8LSB4W2RhdGFQYXJ0LF0NCnlUcmFpbiA8LSB5W2RhdGFQYXJ0LF0NCg0KeFRlc3QgPC0geFstZGF0YVBhcnQsXQ0KeVRlc3QgPC0geVstZGF0YVBhcnQsXQ0KYGBgDQoNCiMjIyBUcmFpbmluZyBEYXRhIFZpc3VhbGl6YXRpb24gYW5kIENvcnJlbGF0aW9uDQoNCkZvciBhIHZpc3VhbCBjb21wb25lbnQgdG8gdGhlIHRyYWluaW5nIGRhdGEsIEkgdXNlIHRoZSBzdGFuZGFyZCBib3hwbG90IGFuZCBoaXN0b2dyYW0gY2hhcnRzLiAgSSBhbHNvIHBsb3QgdGhlIGNvcnJlbGF0aW9uIGJldHdlZW4gdmFyaWFibGVzLiAgQXQgdGhpcyBwb2ludCBJIG5vdGUgdGhhdCBubyBjb3JyZWxhdGlvbiBzY3JlZW5pbmcgc2VlbXMgbmVjZXNzYXJ5IGZvciB0aGlzIGRhdGEsIGFuZCB0aGUgb25seSBvdXRsaWVycyBhcmUgaW4gdGhlIGRlcGVuZGVudCB2YXJpYWJsZSBkYXRhLg0KYGBge3J9DQojIENyZWF0ZSBudW1lcmljIHNldCBvZiB4IHZhcmlhYmxlcw0KeFRyYWluTnVtIDwtIGFzLmRhdGEuZnJhbWUoc2FwcGx5KHhUcmFpbiwgYXMubnVtZXJpYykpDQoNCiMgQm94cGxvdCBvZiBwcmVkaWN0b3JzDQpib3hwbG90KHhUcmFpbk51bSkNCmJveHBsb3QoeVRyYWluKQ0KDQojIEhpc3RvZ3JhbXMNCmhpc3QoeFRyYWluTnVtJENhbXBhaWduKQ0KaGlzdCh4VHJhaW5OdW0kU3BlbmRpbmcpDQpoaXN0KHlUcmFpbikNCg0KIyBDb3JyZWxhdGlvbiBwbG90cyBhbmQgY29ycmVsYXRpb24gY2VpbGluZw0KY29ycmVsYXRpb25zIDwtIGNvcih4VHJhaW5OdW0pDQpjb3JycGxvdChjb3JyZWxhdGlvbnMsIG1ldGhvZCA9ICJudW1iZXIiLCBvcmRlciA9ICJoY2x1c3QiKQ0KYGBgDQoNCiMjIE1MIEFsZ29yaXRobSBUcmFpbmluZw0KDQojIyMgUHJlLXByb2Nlc3NpbmcNCg0KSSBwcmVwcm9jZXNzZWQgdXNpbmcgdGhlIHN0YW5kYXJkICpjZW50ZXIvc2NhbGluZyogYW5kICpZZW9Kb2huc29uKiB0byBub3JtYWxpemUgdGhlIGRhdGEsIGFuZCAqc3BhdGlhbFNpZ24qIGZvciBvdXRsaWVycyAod2hpY2ggSSBkb24ndCB0aGluayBpcyBuZWNlc3NhcnksIGJ1dCBJIHVzdWFsbHkgYWRkIGl0IGFueXdheSBhcyBpdCBtaWdodCBib29zdCBwZXJmb3JtYW5jZSBhIGJpdCkuICBJIGFsc28gdXNlZCAxMC1mb2xkIHJlcGVhdGVkIGNyb3NzIHZhbGlkYXRpb24gZm9yIHJlc2FtcGxpbmcuICBJbiBhZGRpdGlvbiwgSSB1c2VkIGR1bW15IHZhcmlhYmxlcyB0byBhY2NvdW50IGZvciB0aGUgQ2FtcGFpZ24gZmFjdG9yIHZhcmlhYmxlLCBhbmQgdHVuZWQgYWxsIG1vZGVscy4NCg0KIyMgTW9kZWxzIGFuZCBUdW5lZCBQZXJmb3JtYW5jZQ0KDQpVc2luZyB0aGUgKmNhcmV0KiB3b3JrZmxvdywgSSB0cmFpbmVkIHRoZSBkYXRhIG9uIHRoZSBmb2xsb3dpbmcgbW9kZWxzOg0KDQoqIEJvb3N0ZWQgTGluZWFyIFJlZ3Jlc3Npb24gKihCc3RMbSkqDQoqIEVsYXN0aWMgTmV0IFJlZ3Jlc3Npb24gKihlbmV0KSoNCiogR2VuZXJhbGl6ZWQgTGluZWFyIFJlZ3Jlc3Npb24gKihnbG1uZXQpKg0KKiBMZWFzdCBBbmdsZSBSZWdyZXNzaW9uICoobGFycykqDQoqIFJhbmRvbSBGb3Jlc3QgKihyZikqDQoNCk9mIHRoZSBtb2RlbHMgSSB0cmFpbmVkLCAqJ3JmJyogcmFuZG9tIGZvcmVzdCBzZWVtZWQgdG8gaGF2ZSB0aGUgYmVzdCBwZXJmb3JtYW5jZSB3aXRoIGFuIFJNU0UgPSA0Ljk5IGFuZCBSXjJeID0gMC43NC4NCmBgYHtyfQ0KIyBNb2RlbCBzZXQgdXANCmRteSA8LSBkdW1teVZhcnMoIiB+IC4iLCBkYXRhID0geFRyYWluLCBmdWxsUmFuayA9IFQpDQp4VHJhaW5EdW1teSA8LSBkYXRhLmZyYW1lKHByZWRpY3QoZG15LCBuZXdkYXRhID0geFRyYWluKSkNCg0Kc2V0LnNlZWQoc2VlZCkNCmN0cmwgPC0gdHJhaW5Db250cm9sKG1ldGhvZCA9ICJyZXBlYXRlZGN2IiwgbnVtYmVyID0gMTAsIHJlcGVhdHMgPSAxMCkNCg0KDQojIFByZWRpY3RpdmUgbW9kZWxpbmcgdy8gYnVpbHQtaW4gZmVhdHVyZSBzZWxlY3Rpb24NCmdyaWRCc3RMbSA8LSBleHBhbmQuZ3JpZChtc3RvcCA9IHNlcSg4LCAxMiwgYnkgPSAxKSwgbnUgPSAwLjUpDQpzZXQuc2VlZChzZWVkKQ0KbW9kZWxCc3RMbSA8LSB0cmFpbih4ID0geFRyYWluRHVtbXksIHkgPSB5VHJhaW4sIA0KICAgICAgICAgICAgICAgIG1ldGhvZCA9ICJCc3RMbSIsIA0KICAgICAgICAgICAgICAgIHByZVByb2MgPSBjKCJjZW50ZXIiLCAic2NhbGUiLCAiWWVvSm9obnNvbiIsICJzcGF0aWFsU2lnbiIpLA0KICAgICAgICAgICAgICAgIHR1bmVHcmlkID0gZ3JpZEJzdExtLA0KICAgICAgICAgICAgICAgIHRyQ29udHJvbCA9IGN0cmwpDQptb2RlbEJzdExtICMgUk1TRSA9IDUuMzYNCg0KZ3JpZEVuZXQgPC0gZXhwYW5kLmdyaWQoZnJhY3Rpb24gPSBzZXEoMC42LCAwLjgsIGJ5ID0gMC4xKSwgbGFtYmRhID0gMCkNCnNldC5zZWVkKHNlZWQpDQptb2RlbEVuZXQgPC0gdHJhaW4oeCA9IHhUcmFpbkR1bW15LCB5ID0geVRyYWluLCANCiAgICAgICAgICAgICAgICBtZXRob2QgPSAiZW5ldCIsIA0KICAgICAgICAgICAgICAgIHByZVByb2MgPSBjKCJjZW50ZXIiLCAic2NhbGUiLCAiWWVvSm9obnNvbiIsICJzcGF0aWFsU2lnbiIpLA0KICAgICAgICAgICAgICAgIHR1bmVHcmlkID0gZ3JpZEVuZXQsDQogICAgICAgICAgICAgICAgdHJDb250cm9sID0gY3RybCkNCm1vZGVsRW5ldCAjIFJNU0UgPSA1LjE1DQoNCmdyaWRHbG1OZXQgPC0gZXhwYW5kLmdyaWQoYWxwaGEgPSBjKDAuOSwgMSwgMS4xKSwgbGFtYmRhID0gYygwLjYsIDAuNywgMC44KSkNCnNldC5zZWVkKHNlZWQpDQptb2RlbEdsbU5ldCA8LSB0cmFpbih4ID0geFRyYWluRHVtbXksIHkgPSB5VHJhaW4sIA0KICAgICAgICAgICAgICAgIG1ldGhvZCA9ICJnbG1uZXQiLA0KICAgICAgICAgICAgICAgIHByZVByb2MgPSBjKCJjZW50ZXIiLCAic2NhbGUiLCAiWWVvSm9obnNvbiIsICJzcGF0aWFsU2lnbiIpLA0KICAgICAgICAgICAgICAgIHR1bmVHcmlkID0gZ3JpZEdsbU5ldCwNCiAgICAgICAgICAgICAgICB0ckNvbnRyb2wgPSBjdHJsKQ0KbW9kZWxHbG1OZXQgIyBSTVNFID0gNS4xNw0KDQpncmlkTGFycyA8LSBleHBhbmQuZ3JpZChmcmFjdGlvbiA9IGMoMC42NSwgMC43LCAwLjc1KSkNCnNldC5zZWVkKHNlZWQpDQptb2RlbExhcnMgPC0gdHJhaW4oeCA9IHhUcmFpbkR1bW15LCB5ID0geVRyYWluLCANCiAgICAgICAgICAgICAgICBtZXRob2QgPSAibGFycyIsIA0KICAgICAgICAgICAgICAgIHByZVByb2MgPSBjKCJjZW50ZXIiLCAic2NhbGUiLCAiWWVvSm9obnNvbiIsICJzcGF0aWFsU2lnbiIpLA0KICAgICAgICAgICAgICAgIHR1bmVHcmlkID0gZ3JpZExhcnMsDQogICAgICAgICAgICAgICAgdHJDb250cm9sID0gY3RybCkNCm1vZGVsTGFycyAjIFJNU0UgPSA1LjE0DQoNCmdyaWRSRiA8LSBleHBhbmQuZ3JpZChtdHJ5ID0gYygxLjUsIDIsIDIuNSkpDQpzZXQuc2VlZChzZWVkKQ0KbW9kZWxSRiA8LSB0cmFpbih4ID0geFRyYWluRHVtbXksIHkgPSB5VHJhaW4sIA0KICAgICAgICAgICAgICAgIG1ldGhvZCA9ICJyZiIsIA0KICAgICAgICAgICAgICAgIHByZVByb2MgPSBjKCJjZW50ZXIiLCAic2NhbGUiLCAiWWVvSm9obnNvbiIsICJzcGF0aWFsU2lnbiIpLA0KICAgICAgICAgICAgICAgIHR1bmVHcmlkID0gZ3JpZFJGLA0KICAgICAgICAgICAgICAgIHRyQ29udHJvbCA9IGN0cmwpDQptb2RlbFJGICMgUk1TRSA9IDQuOTksIFJeMiA9IDAuNzQNCg0KYGBgDQoNCiMjIyBNb2RlbCBQZXJmb3JtYW5jZSBDb21wYXJpc29uIFZpc3VhbGl6YXRpb24NCg0KVGhlIGJlbG93IHRhYmxlcyBhbmQgZ3JhcGhzIHNob3cgdGhhdCB0aG91Z2ggdGhlIHBlcmZvcm1hbmNlIGlzIHNpbWlsYXIgYWNyb3NzIHRoZSBtb2RlbHMsIConcmYnKiByYW5kb20gZm9yZXN0IHBlcmZvcm1zIGEgbGl0dGxlIGJldHRlciBvbiB0aGUgZGF0YSB0aGFuIHRoZSBvdGhlciBtb2RlbHMuICBJbiBhZGRpdGlvbiwgdGhlIGxhcmdlc3Qgc3RhdGlzdGljYWwgZGlmZmVyZW5jZSBsaWVzIGJldHdlZW4gKidyZicqIHJhbmRvbSBmb3Jlc3QgYW5kIConQnN0TG0nKiBib29zdGVkIGxpbmVhciByZWdyZXNzaW9uLCBzbyBJIG1heSB3YW50IHRvIHVzZSB0aGVtIHRvZ2V0aGVyIGlmIEkgZGVjaWRlIHRvIHRyYWluIGEgbW9kZWwgZW5zZW1ibGUuDQpgYGB7cn0NCiMgQ29tcGFyZSBtb2RlbCBwZXJmb3JtYW5jZXMNCm1vZGVsUmVzdWx0cyA8LSByZXNhbXBsZXMobGlzdChCc3RMbSA9IG1vZGVsQnN0TG0sIEVuZXQgPSBtb2RlbEVuZXQsIEdsbU5ldCA9IG1vZGVsR2xtTmV0LA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIExhcnMgPSBtb2RlbExhcnMsIFJGID0gbW9kZWxSRikpDQpzdW1tYXJ5KG1vZGVsUmVzdWx0cykNCmRvdHBsb3QobW9kZWxSZXN1bHRzKQ0KDQojIENoZWNrIGRpZmZlcmVuY2VzDQptb2RlbERpZmYgPC0gZGlmZihtb2RlbFJlc3VsdHMpDQpzdW1tYXJ5KG1vZGVsRGlmZikNCmRvdHBsb3QobW9kZWxEaWZmKQ0KYGBgDQoNCiMjIEZpbmFsaXplZCBNb2RlbCBQZXJmb3JtYW5jZSBvbiBUZXN0IERhdGENCg0KVGhlIGZpbmFsIFJNU0UgYW5kIFJeMl4gZm9yIHRoZSAqJ3JmJyogcmFuZG9tIGZvcmVzdCBtb2RlbCBvbiB0aGUgdGVzdCBkYXRhOg0KYGBge3J9DQojIFRyYW5zZm9ybSB0ZXN0IGRhdGENCmRteVRlc3QgPC0gZHVtbXlWYXJzKCIgfiAuIiwgZGF0YSA9IHhUZXN0LCBmdWxsUmFuayA9IFQpDQp4VGVzdER1bW15IDwtIGRhdGEuZnJhbWUocHJlZGljdChkbXlUZXN0LCBuZXdkYXRhID0geFRlc3QpKQ0KDQojIEV2YWx1YXRlIG9uIHRlc3QgZGF0YQ0KcHJlZGljdFJGIDwtIHByZWRpY3QobW9kZWxSRiwgbmV3ZGF0YSA9IHhUZXN0RHVtbXkpDQpSTVNFKHByZWRpY3RSRiwgeVRlc3QpDQpSMihwcmVkaWN0UkYsIHlUZXN0KQ0KYGBgDQoNCiMjIyBGaW5hbCBNb2RlbCBUZXN0IERldGFpbHMNCg0KVGhlIGZpbmFsIConcmYnKiBtb2RlbCByZXR1cm5lZCBkaXNhcHBvaW50aW5nIHJlc3VsdHMgZm9yIHRoZSB0ZXN0IGRhdGEuICBPbmx5IDQ5JSBvZiB0aGUgdmFyaWFuY2UgY2FuIGJlIGV4cGxhaW5lZCBieSB0aGUgbW9kZWwgd2hpY2ggcmV0dXJuZWQgYW4gUk1TRSA9IDYuNjEsIGEgbG90IGhpZ2hlciBvZiBhbiBlcnJvciB0aGFuIHdpdGggdGhlIHRyYWluaW5nIHNldC4gIEZpbmFsIG1vZGVsIGRldGFpbHMgYXJlIGdpdmVuOiANCmBgYHtyfQ0KIyBGaW5hbGl6ZSBNb2RlbA0KbGlicmFyeShyYW5kb21Gb3Jlc3QpDQpzZXQuc2VlZChzZWVkKQ0KcHJlUHJvY1BhcmFtcyA8LSBwcmVQcm9jZXNzKHhUcmFpbkR1bW15LCBtZXRob2QgPSBjKCJjZW50ZXIiLCAic2NhbGUiLCAiWWVvSm9obnNvbiIsICJzcGF0aWFsU2lnbiIpKQ0KeFRyYWluVHJhbnMgPC0gcHJlZGljdChwcmVQcm9jUGFyYW1zLCB4VHJhaW5EdW1teSkNCmZpbmFsTW9kZWwgPC0gcmFuZG9tRm9yZXN0KHggPSB4VHJhaW5UcmFucywgeSA9IHlUcmFpbiwgbXN0b3AgPSAxMCkNCmZpbmFsTW9kZWwNCnN1bW1hcnkoZmluYWxNb2RlbCkNCg0KIyBFdmFsdWF0ZSBvbiB0ZXN0IGRhdGENCnhUZXN0TnVtIDwtIGFzLmRhdGEuZnJhbWUoc2FwcGx5KHhUZXN0LCBhcy5udW1lcmljKSkNCnNldC5zZWVkKHNlZWQpDQp4VGVzdFRyYW5zIDwtIHByZWRpY3QocHJlUHJvY1BhcmFtcywgeFRlc3REdW1teSkNCmZpbmFsTW9kZWxQcmVkaWN0IDwtIHByZWRpY3QoZmluYWxNb2RlbCwgbmV3ZGF0YSA9IHhUZXN0VHJhbnMsIG1zdG9wID0gMTApDQoNCiMgQ2FsY3VsYXRlIHBlcmZvcm1hbmNlDQpmaW5hbE1vZGVsUk1TRSA8LSBSTVNFKGZpbmFsTW9kZWxQcmVkaWN0LCB5VGVzdCkNCmZpbmFsTW9kZWxSMiA8LSBSMihmaW5hbE1vZGVsUHJlZGljdCwgeVRlc3QpDQpmaW5hbE1vZGVsUk1TRQ0KYGBgDQoNCkF0IHRoaXMgcG9pbnQsIGl0IHNlZW1zIHRoZSBtb2RlbCBtYXkgYmUgKipvdmVyZml0KiouICBGb3Igbm93LCBsZXQncyBwcm9jZWVkIHRvIHRyeWluZyBhbiBlbnNlbWJsZSBvZiB0aGUgKioncmYnKiogcmFuZG9tIGZvcmVzdCBhbmQgKionQnN0TG0nKiogYm9vc3RlZCBsaW5lYXIgbW9kZWxzLg0KDQoNCiMjIyBUcmFpbiBhbmQgRXZhbHVhdGUgJ3JmJyArICdCc3RMbScgTW9kZWwgRW5zZW1ibGUNCg0KVGhlICoqJ3JmJyArICdCc3RMbScqKiBtb2RlbCBlbnNlbWJsZSBwZXJmb3JtZWQgbXVjaCBiZXR0ZXIgb24gdGhlIHRyYWluaW5nIGRhdGEgdGhhbiBlaXRoZXIgbW9kZWwgYWxvbmUuICBIb3dldmVyLCB0aGUgZW5zZW1ibGUgcmVzdWx0ZWQgaW4gYSBzbGlnaHQgZGVjcmVhc2UgaW4gUk1TRSBwZXJmb3JtYW5jZSBhbmQgYSBzbGlnaHQgaW5jcmVhc2UgaW4gUl4yXiBwZXJmb3JtYW5jZSBvbiB0aGUgdGVzdCBkYXRhLg0KYGBge3J9DQpsaWJyYXJ5KGNhcmV0RW5zZW1ibGUpDQoNCiMgVHJhaW4gYSBsaXN0IG9mIG1vZGVscyB3aXRoIGNhcmV0TGlzdCgpDQpsaXN0T2ZNb2RlbHMgPC0gYygncmYnLCAnQnN0TG0nKQ0Kc2V0LnNlZWQoc2VlZCkNCm1vZGVsTGlzdCA8LSBjYXJldExpc3QoeCA9IHhUcmFpbkR1bW15LCB5ID0geVRyYWluLCANCiAgICAgICAgICAgICAgICAgICAgdHJDb250cm9sID0gY3RybCwNCiAgICAgICAgICAgICAgICAgICAgcHJlUHJvYyA9IGMoImNlbnRlciIsICJzY2FsZSIsICJZZW9Kb2huc29uIiwgInNwYXRpYWxTaWduIiksIA0KICAgICAgICAgICAgICAgICAgICBtZXRob2RMaXN0ID0gbGlzdE9mTW9kZWxzKQ0KDQojIE1vZGVsIHJlc3VsdHMgYW5kIGNvcnJlbGF0aW9uDQpyZXN1bHRzIDwtIHJlc2FtcGxlcyhtb2RlbExpc3QpDQpzdW1tYXJ5KHJlc3VsdHMpDQpkb3RwbG90KHJlc3VsdHMpDQptb2RlbENvcihyZXN1bHRzKQ0KDQojIENvbWJpbmUgdGhlIG1vZGVscyB3aXRoIGNhcmV0RW5zZW1ibGUoKQ0Kc2V0LnNlZWQoc2VlZCkNCm1vZGVsRW5zZW1ibGUgPC0gY2FyZXRFbnNlbWJsZShtb2RlbExpc3QsIG1ldHJpYyA9ICJSTVNFIiwNCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHRyQ29udHJvbCA9IHRyYWluQ29udHJvbChudW1iZXIgPSAyKSkNCg0Kc3VtbWFyeShtb2RlbEVuc2VtYmxlKSAjIFJNU0UgPSA1LjA0NjMNCg0KIyBFdmFsdWF0ZSBvbiB0ZXN0IGRhdGENCnByZWRpY3RFbnNlbWJsZSA8LSBwcmVkaWN0KG1vZGVsRW5zZW1ibGUsIG5ld2RhdGEgPSB4VGVzdER1bW15KQ0KUk1TRShwcmVkaWN0RW5zZW1ibGUsIHlUZXN0KQ0KUjIocHJlZGljdEVuc2VtYmxlLCB5VGVzdCkNCmBgYA0KU2luY2Ugb3ZlcmFsbCBwZXJmb3JtYW5jZSBpcyBjb21wYXJhYmxlLCBJJ2xsIGdvIGFoZWFkIGFuZCByb2xsIHdpdGggaXQgZm9yIHRoZSBlbnJvbGxtZW50IGZvcmVjYXN0aW5nLg0KDQojIERhdGEgU29sdXRpb24NCg0KIyMjIEZpbmFsIE1vZGVsIFByZWRpY3Rpb246IEZvcmVjYXN0ZWQgRW5yb2xsbWVudHMNCg0KVG8gZm9yZWNhc3QgZnV0dXJlIHN0dWRlbnQgZW5yb2xsbWVudHMsIEkgdXNlZCB0aGUgZm9sbG93aW5nIERlY2VtYmVyIDIwMTkgc3BlbmRpbmcgZmlndXJlczoNCg0KKiAqKkxWTioqOiAkMSwyNzEuODYNCiogKipNZWQgQXNzdCoqOiAkMiw1NjguMDANCiogKipQaGFybWFjeSoqOiAkMSwwMTUuNjQNCiogKipOdXJzZSBBaWRlKio6ICQ5MzkuNzUNCiogKipNZWQgQXNzdCoqOiAkMSw2ODguNTQNCg0KTGV0J3MgYXNzdW1lIHRoZSBmb2xsb3dpbmcgc3BlbmRpbmcgaW5jcmVhc2VzIGJ5IExvbmUgU3RhciBNZWRpY2FsIENhcmVlciBDb2xsZWdlOg0KDQoqIERlYyArIDEwJQ0KKiBEZWMgKyAxNSUNCiogRGVjICsgMjAlDQoNCkJhc2VkIG9uIHRoZSBkaWZmZXJlbnQgY2FtcGFpZ24gY2F0ZWdvcmllcyBhbmQgdGhlc2UgaW5jcmVhc2VzIGluIHNwZW5kaW5nLCB0aGUgdGhpcmQgY29sdW1uIGxpc3RzIHRoZSBmb3JlY2FzdGVkIHN0dWRlbnQgZW5yb2xsbWVudHMuDQpgYGB7cn0NCiMgSW1wb3J0IGFuZCBjbGVhbiBkYXRhDQpwcmVkRGF0YSA8LSByZWFkLmNzdigiQzovVXNlcnMvZ2VvcmdlL0RvY3VtZW50cy9EaWdpdGFsIE1hcmtldGluZyBEYXRhL1RyaWdnZXJzL3ByZWRpY3RlZFNwZW5kaW5nQW5kRW5yb2xsbWVudHMuY3N2IikNCg0KIyBUcmFuc2Zvcm0gZm9yZWNhc3QgZGF0YQ0KZG15UHJlZERhdGEgPC0gZHVtbXlWYXJzKCIgfiAuIiwgZGF0YSA9IHByZWREYXRhLCBmdWxsUmFuayA9IFQpDQpwcmVkRHVtbXkgPC0gZGF0YS5mcmFtZShwcmVkaWN0KGRteVByZWREYXRhLCBuZXdkYXRhID0gcHJlZERhdGEpKQ0KDQojIFByZWRpY3QgZm9yZWNhc3QgZGF0YQ0KcHJlZEVucm9sbG1lbnRzIDwtIGRhdGEuZnJhbWUocHJlZGljdChtb2RlbEVuc2VtYmxlLCBuZXdkYXRhID0gcHJlZER1bW15KSkNCnByZWRFeHBvcnQgPC0gZGF0YS5mcmFtZShjKHByZWREYXRhLHByZWRFbnJvbGxtZW50cykpDQpwcmVkRXhwb3J0DQojd3JpdGUuY3N2KHByZWRFeHBvcnQsICJDOi9Vc2Vycy9nZW9yZ2UvRG9jdW1lbnRzL0RpZ2l0YWwgTWFya2V0aW5nIERhdGEvVHJpZ2dlcnMvcHJlZGljdGVkRXhwb3J0LmNzdiIsIHJvdy5uYW1lcyA9IEZBTFNFKQ0KYGBgDQoNCiMgQnVzaW5lc3MgU29sdXRpb24NCg0KT3ZlcmFsbCwgdGhlIConcmYnKydCc3RMbScqIGVuc2VtYmxlIG1vZGVsIGluZGljYXRlcyB0aGF0ICoqaW5jcmVtZW50YWwgaW5jcmVhc2VzIGluIHNwZW5kaW5nIHNob3VsZCBoYXZlIGEgcG9zaXRpdmUgZWZmZWN0IG9uIHRoZSBudW1iZXIgb2Ygb3ZlcmFsbCBtZWRpY2FsIHN0dWRlbnQgZW5yb2xsbWVudHMuKiogIEhvd2V2ZXIsIGR1ZSB0byBsb3cgbW9kZWwgcGVyZm9ybWFuY2UsIHRoZXNlIHByZWRpY3Rpb25zIGFyZSBub3QgdmVyeSBjb25jbHVzaXZlLiAgRnV0dXJlIHByb2plY3RzIHNob3VsZCBwcm92aWRlIGhpZ2hlciBxdWFsaXR5IGRhdGEgYW5kIGJldHRlciBtb2RlbGluZyBwYXJhbWV0ZXJzLg0KDQpTaW5jZSB0aGlzIGlzIHRoZSBmaXJzdCBraW5kIG9mIGFwcGxpZWQgZGF0YSBtaW5pbmcgcHJvamVjdCBmb3IgYm90aCB0aGUgbWVkaWNhbCBzY2hvb2wgYW5kIHRoZWlyIGRpZ2l0YWwgbWFya2V0aW5nIGNvbXBhbnksIHRoZSB0cmFuc2FjdGlvbiBjaGFubmVsIGJldHdlZW4gY29tcGFueSBhZCBzcGVuZGluZyBhbmQgc3R1ZGVudCBlbnJvbGxtZW50IGNvdW50cyBpcyBub3QgeWV0IGF1dG9tYXRlZCBpbnRvIGEgZGF0YWJhc2UsIGFuZCBpdCBpcyBub3QgY2xlYXIgaG93IGVpdGhlciBjb21wYW55IGNvbW11bmljYXRlcyB0aGlzIHR5cGUgb2YgUk9JIHdpdGggZWFjaCBvdGhlci4gIEluIG90aGVyIHdvcmRzLCB3ZSBjYW4gbm90IGZ1bGx5IGFjY291bnQgZm9yIHRoZSBhbW91bnQgb2Ygc3R1ZGVudCBlbnJvbGxtZW50cyB0aGF0IGFyZSBzb2xlbHkgYXR0cmlidXRlZCB0byBhZC1nZW5lcmF0ZWQgbGVhZHMgYW5kIHNwZW5kaW5nLiAgVW5mb3J0dW5hdGVseSwgdGhpcyBpcyBhIG1ham9yIGlzc3VlIHdoZW4gYXR0ZW1wdGluZyB0byBmb3JlY2FzdCBmdXR1cmUgZW5yb2xsbWVudHMuICBEdWUgdG8gdGhpcyBmYWN0LCB0aGUgbW9kZWwgZGV2ZWxvcGVkIGFwcGVhcnMgdG8gYmUgb3ZlcmZpdCBhbmQgZG9lcyBub3QgaGFuZGxlIG5ldyBkYXRhIGFzIHdlbGwgYXMgaG9wZWQuDQoNCkZ1dHVyZSBkZXZlbG9wbWVudHMgbWF5IGluY2x1ZGUgbW9yZSBncmFpbnVsYXIsIGhvdXJseSBkYXRhIHNvdXJjZWQgZnJvbSB0aGUgb25saW5lIHBsYXRmb3JtcyB0aGVtc2VsdmVzIGFuZCBwZXJoYXBzIGRldmVsb3BpbmcgYSBsZXNzIG92ZXJmaXQgbW9kZWwuICBJbiBhZGRpdGlvbiwgbWF5YmUgYSBuZXcgc3RyYXRlZ3kgZm9yIHNlZWluZyBhZC1nZW5lcmF0ZWQgbGVhZHMgdGhyb3VnaCB0byBlbnJvbGxtZW50cyBjYW4gYmUgb3B0aW1pemVkIGZvciBtdWNoIGJldHRlciBtYXJrZXRpbmcgZnVubmVsIGJ1c2luZXNzIGludGVsbGlnZW5jZSBwZXJmb3JtYW5jZS4gIFRoaXMgcHJvamVjdCBkZW1vbnN0cmF0ZXMgdGhlIGltcG9ydGFuY2Ugb2Ygc3RyZWFtbGluaW5nIGJ1c2luZXNzIHByb2Nlc3NlcyBmb3Igb3B0aW1pemVkIGludGVsbGlnZW5jZS4=

Updated: