Introduction

Nowadays churn predicition becomes very popular and important for many companies to analyse which customers will stop using their sevices so that they can adjuste their business to fit well the marketing.

In this project, dataset(Churn_Modelling.csv) will be used to analyse churn prediction.

And these following modeles will be introduced: 1, Logistic Regression 2, Decision Tree 3, Random Forest

This report is also availible online, see http://rpubs.com/jz8/Churnprediction.

Data Processing

Load libraries

library(ggplot2)
library(gridExtra)
library(ggthemes)
library(caret)
library(plyr)
library(corrplot)
library(MASS)
library(randomForest)
library(party)
library(dplyr)
library(rpart)
library(rpart.plot)

Load data

churn <- read.csv('Churn_Modelling.csv')
dim(churn)
[1] 10000    14

Prepare the dataset

names(churn)
 [1] "RowNumber"       "CustomerId"      "Surname"         "CreditScore"     "Geography"       "Gender"          "Age"            
 [8] "Tenure"          "Balance"         "NumOfProducts"   "HasCrCard"       "IsActiveMember"  "EstimatedSalary" "Exited"         
str(churn)
'data.frame':   10000 obs. of  14 variables:
 $ RowNumber      : int  1 2 3 4 5 6 7 8 9 10 ...
 $ CustomerId     : int  15634602 15647311 15619304 15701354 15737888 15574012 15592531 15656148 15792365 15592389 ...
 $ Surname        : Factor w/ 2932 levels "Abazu","Abbie",..: 1116 1178 2041 290 1823 538 178 2001 1147 1082 ...
 $ CreditScore    : int  619 608 502 699 850 645 822 376 501 684 ...
 $ Geography      : Factor w/ 3 levels "France","Germany",..: 1 3 1 1 3 3 1 2 1 1 ...
 $ Gender         : Factor w/ 2 levels "Female","Male": 1 1 1 1 1 2 2 1 2 2 ...
 $ Age            : int  42 41 42 39 43 44 50 29 44 27 ...
 $ Tenure         : int  2 1 8 1 2 8 7 4 4 2 ...
 $ Balance        : num  0 83808 159661 0 125511 ...
 $ NumOfProducts  : int  1 1 3 2 1 2 2 4 2 1 ...
 $ HasCrCard      : int  1 0 1 0 1 1 1 1 0 1 ...
 $ IsActiveMember : int  1 1 0 0 1 0 1 0 1 1 ...
 $ EstimatedSalary: num  101349 112543 113932 93827 79084 ...
 $ Exited         : int  1 0 1 0 0 1 0 1 0 0 ...
head(churn)
sapply(churn, function(x) sum(is.na(x)))
      RowNumber      CustomerId         Surname     CreditScore       Geography          Gender             Age          Tenure 
              0               0               0               0               0               0               0               0 
        Balance   NumOfProducts       HasCrCard  IsActiveMember EstimatedSalary          Exited 
              0               0               0               0               0               0 
churnData <- churn %>%  select(-c(RowNumber)) %>%  subset(!duplicated(churn$CustomerId))
dim(churnData)
[1] 10000    13
summary(churnData)
   CustomerId           Surname      CreditScore      Geography       Gender          Age            Tenure          Balance      
 Min.   :15565701   Smith   :  32   Min.   :350.0   France :5014   Female:4543   Min.   :18.00   Min.   : 0.000   Min.   :     0  
 1st Qu.:15628528   Martin  :  29   1st Qu.:584.0   Germany:2509   Male  :5457   1st Qu.:32.00   1st Qu.: 3.000   1st Qu.:     0  
 Median :15690738   Scott   :  29   Median :652.0   Spain  :2477                 Median :37.00   Median : 5.000   Median : 97199  
 Mean   :15690941   Walker  :  28   Mean   :650.5                                Mean   :38.92   Mean   : 5.013   Mean   : 76486  
 3rd Qu.:15753234   Brown   :  26   3rd Qu.:718.0                                3rd Qu.:44.00   3rd Qu.: 7.000   3rd Qu.:127644  
 Max.   :15815690   Genovese:  25   Max.   :850.0                                Max.   :92.00   Max.   :10.000   Max.   :250898  
                    (Other) :9831                                                                                                 
 NumOfProducts    HasCrCard      IsActiveMember   EstimatedSalary         Exited      
 Min.   :1.00   Min.   :0.0000   Min.   :0.0000   Min.   :    11.58   Min.   :0.0000  
 1st Qu.:1.00   1st Qu.:0.0000   1st Qu.:0.0000   1st Qu.: 51002.11   1st Qu.:0.0000  
 Median :1.00   Median :1.0000   Median :1.0000   Median :100193.91   Median :0.0000  
 Mean   :1.53   Mean   :0.7055   Mean   :0.5151   Mean   :100090.24   Mean   :0.2037  
 3rd Qu.:2.00   3rd Qu.:1.0000   3rd Qu.:1.0000   3rd Qu.:149388.25   3rd Qu.:0.0000  
 Max.   :4.00   Max.   :1.0000   Max.   :1.0000   Max.   :199992.48   Max.   :1.0000  
                                                                                      
churnData$HasCrCard <- as.factor(mapvalues(churnData$HasCrCard, from = c("0", "1"), to = c("No", "Yes")))
churnData$IsActiveMember <- as.factor(mapvalues(churnData$IsActiveMember, from = c("0", "1"), to = c("No", "Yes")))

Remove no useful variables.

churnData$CustomerId <- NULL 
churnData$Surname  <- NULL 

Exploratory data

Correlation between numeric variables


numeric_var <- sapply(churnData, is.numeric)
corr_matrix <- cor(churnData[, numeric_var])
corrplot(corr_matrix, main = "\n\nCorrelation Plot for Numerical Variables", method = "number")

These variables are not very correlated so that they are all kept.

str(churnData)
'data.frame':   10000 obs. of  11 variables:
 $ CreditScore    : int  619 608 502 699 850 645 822 376 501 684 ...
 $ Geography      : Factor w/ 3 levels "France","Germany",..: 1 3 1 1 3 3 1 2 1 1 ...
 $ Gender         : Factor w/ 2 levels "Female","Male": 1 1 1 1 1 2 2 1 2 2 ...
 $ Age            : int  42 41 42 39 43 44 50 29 44 27 ...
 $ Tenure         : int  2 1 8 1 2 8 7 4 4 2 ...
 $ Balance        : num  0 83808 159661 0 125511 ...
 $ NumOfProducts  : int  1 1 3 2 1 2 2 4 2 1 ...
 $ HasCrCard      : Factor w/ 2 levels "No","Yes": 2 1 2 1 2 2 2 2 1 2 ...
 $ IsActiveMember : Factor w/ 2 levels "No","Yes": 2 2 1 1 2 1 2 1 2 2 ...
 $ EstimatedSalary: num  101349 112543 113932 93827 79084 ...
 $ Exited         : int  1 0 1 0 0 1 0 1 0 0 ...
p1 <- ggplot(churnData, aes(x=CreditScore )) + ggtitle("Credit Score ") + xlab("Credit Score ") +
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) +
      ylab("Percentage") + coord_flip() + theme_minimal()

p2 <- ggplot(churnData, aes(x=Geography)) + ggtitle("Geography") + 
      xlab("Geography") + geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + ylab("Percentage") + coord_flip() + theme_minimal()

p3 <- ggplot(churnData, aes(x=Gender)) + ggtitle("Gender") + xlab("Gender") + 
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + 
      ylab("Percentage") + coord_flip() + theme_minimal()

p4 <- ggplot(churnData, aes(x=Age)) + ggtitle("Age") + xlab("Age") + 
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + 
      ylab("Percentage") + coord_flip() + theme_minimal()

grid.arrange(p1, p2, p3, p4, ncol=2)

p5 <- ggplot(churnData, aes(x=Tenure)) + ggtitle("Tenure") + 
      xlab("Tenure") + 
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + 
      ylab("Percentage") + coord_flip() + theme_minimal()
p6 <- ggplot(churnData, aes(x=NumOfProducts )) + ggtitle("Num Of Products ") + 
      xlab("Multiple Lines") + 
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + 
      ylab("Num Of Products ") + coord_flip() + theme_minimal()

p7 <- ggplot(churnData, aes(x=HasCrCard)) + ggtitle("Has Credit Card") + 
      xlab("Has Credit Card") + 
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + 
      ylab("Percentage") + coord_flip() + theme_minimal()

p8 <- ggplot(churnData, aes(x=IsActiveMember)) + ggtitle("Is Active Member ") + 
      xlab("Is Active Member ") + 
      geom_bar(aes(y = 100*(..count..)/sum(..count..)), width = 0.5) + 
      ylab("Percentage") + coord_flip() + theme_minimal()

grid.arrange(p5, p6, p7, p8, ncol=2)

boxplot(churnData$EstimatedSalary, col = grey(0.9), main = "Estimated Salary", xlab = "Estimated Salary",ylab = "Effectif")
abline(h = median(churnData$EstimatedSalary, na.rm = TRUE), col = "navy", lty = 2)
text(1.35, median(churnData$EstimatedSalary, na.rm = TRUE) + 0.15, "Médiane", col = "navy")
Q1 <- quantile(churnData$EstimatedSalary, probs = 0.25, na.rm = TRUE)
abline(h = Q1, col = "darkred")
text(1.35, Q1 + 0.15, "Q1 : premier quartile", col = "darkred", lty = 2)
Q3 <- quantile(churnData$EstimatedSalary, probs = 0.75, na.rm = TRUE)
abline(h = Q3, col = "darkred")
text(1.35, Q3 + 0.15, "Q3 : troisième quartile", col = "darkred", lty = 2)
arrows(x0 = 0.7, y0 = quantile(churnData$EstimatedSalary, probs = 0.75,
                               na.rm = TRUE), x1 = 0.7, y1 = quantile(churnData$EstimatedSalary,
                                                                      probs = 0.25, na.rm = TRUE), length = 0.1, code = 3)
text(0.7, Q1 + (Q3 - Q1)/2 + 0.15, "h", pos = 2)
mtext("L'écart inter-quartile h is about 100000", side = 1)

Modeling

Logistic Regression

Logistic Regression analyse

Split data into training and testing sets:

trainIndex <- createDataPartition(churnData$Exited, p = .75, list = FALSE, times = 1)
set.seed(2019)
training <- churnData[trainIndex, ]
testing <- churnData[- trainIndex, ]

Virifiy the 2 sets:

dim(training)
[1] 7500   11
dim(testing)
[1] 2500   11

Fitting the Logistic Regression Model:

LogModel <- glm(Exited ~ ., family = binomial(link = "logit"), data = training)
print(summary(LogModel))

Call:
glm(formula = Exited ~ ., family = binomial(link = "logit"), 
    data = training)

Deviance Residuals: 
    Min       1Q   Median       3Q      Max  
-2.3148  -0.6540  -0.4537  -0.2661   3.0098  

Coefficients:
                    Estimate Std. Error z value Pr(>|z|)    
(Intercept)       -3.481e+00  2.858e-01 -12.179  < 2e-16 ***
CreditScore       -6.625e-04  3.254e-04  -2.036   0.0418 *  
GeographyGermany   7.196e-01  7.840e-02   9.178  < 2e-16 ***
GeographySpain     5.360e-03  8.144e-02   0.066   0.9475    
GenderMale        -5.477e-01  6.315e-02  -8.673  < 2e-16 ***
Age                7.358e-02  2.991e-03  24.605  < 2e-16 ***
Tenure            -1.146e-02  1.079e-02  -1.063   0.2879    
Balance            3.074e-06  5.943e-07   5.173  2.3e-07 ***
NumOfProducts     -7.875e-02  5.494e-02  -1.433   0.1517    
HasCrCardYes      -6.955e-02  6.844e-02  -1.016   0.3095    
IsActiveMemberYes -1.088e+00  6.704e-02 -16.235  < 2e-16 ***
EstimatedSalary    5.072e-07  5.493e-07   0.923   0.3558    
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 7558.4  on 7499  degrees of freedom
Residual deviance: 6383.0  on 7488  degrees of freedom
AIC: 6407

Number of Fisher Scoring iterations: 5

Feature Analysis:

anova(LogModel, test = "Chisq")
Analysis of Deviance Table

Model: binomial, link: logit

Response: Exited

Terms added sequentially (first to last)

                Df Deviance Resid. Df Resid. Dev  Pr(>Chi)    
NULL                             7499     7558.4              
CreditScore      1     6.18      7498     7552.2   0.01289 *  
Geography        2   203.77      7496     7348.5 < 2.2e-16 ***
Gender           1    83.52      7495     7264.9 < 2.2e-16 ***
Age              1   557.53      7494     6707.4 < 2.2e-16 ***
Tenure           1     0.19      7493     6707.2   0.66618    
Balance          1    34.69      7492     6672.5 3.872e-09 ***
NumOfProducts    1     3.79      7491     6668.7   0.05161 .  
HasCrCard        1     0.67      7490     6668.1   0.41444    
IsActiveMember   1   284.22      7489     6383.9 < 2.2e-16 ***
EstimatedSalary  1     0.85      7488     6383.0   0.35575    
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1

Logistic Regression evaluation

tableLR <- table(Actual=testing$Exited, prediction=fitted_results > 0.5)
tableLR
      prediction
Actual FALSE TRUE
     0  1835  147
     1   476   42
print(paste('Logistic Regression Accuracy',sum(diag(tableLR))/sum(tableLR)))
[1] "Logistic Regression Accuracy 0.7508"
print(paste('Logistic Regression precision',p1<-tableLR[2,2]/(tableLR[2,2]+tableLR[1,2])))
[1] "Logistic Regression precision 0.222222222222222"
print(paste('Logistic Regression recall',r1<-tableLR[2,2]/(tableLR[2,2]+tableLR[2,1])))
[1] "Logistic Regression recall 0.0810810810810811"
print(paste('Logistic Regression F1Score',2*p1*r1/(r1+p1)))
[1] "Logistic Regression F1Score 0.118811881188119"

Odds Ratio (performance measurements in logistic regression, what the odds of an event is happening.)

exp(cbind(OR = coef(LogModel), confint(LogModel)))
Waiting for profiling to be done...
                          OR      2.5 %     97.5 %
(Intercept)       0.03076797 0.01753066 0.05376236
CreditScore       0.99933769 0.99870005 0.99997513
GeographyGermany  2.05353738 1.76136254 2.39523675
GeographySpain    1.00537473 0.85637590 1.17854048
GenderMale        0.57825994 0.51082063 0.65432453
Age               1.07635596 1.07010304 1.08272393
Tenure            0.98860261 0.96791050 1.00972076
Balance           1.00000307 1.00000191 1.00000424
NumOfProducts     0.92427503 0.82958615 1.02898358
HasCrCardYes      0.93281029 0.81609227 1.06727095
IsActiveMemberYes 0.33673319 0.29503972 0.38373937
EstimatedSalary   1.00000051 0.99999943 1.00000158

Decision Tree

Decision Tree analyse

set.seed(91)
DTree <- rpart(Exited ~ ., training, method="class")
plotcp(DTree)

prp(DTree,extra=1)

Decision Tree evaluation

 
tableDT <- table(Actual = testing$Exited, Predicted = predict(DTree, testing, type="class"))
tableDT
      Predicted
Actual    0    1
     0 1935   47
     1  324  194
print(paste('Decision Tree Accuracy',sum(diag(tableDT))/sum(tableDT)))
[1] "Decision Tree Accuracy 0.8516"
print(paste('Decision Tree precision',p2<-tableDT[2,2]/(tableDT[2,2]+tableDT[1,2])))
[1] "Decision Tree precision 0.804979253112033"
print(paste('Decision Tree recall',r2<-tableDT[2,2]/(tableDT[2,2]+tableDT[2,1])))
[1] "Decision Tree recall 0.374517374517374"
print(paste('Decision Tree F1Score',2*p2*r2/(r2+p2)))
[1] "Decision Tree F1Score 0.511198945981555"

Random Forest

Random Forest Initial Model

rfModel <- randomForest(Exited ~., data = training)
The response has five or fewer unique values.  Are you sure you want to do regression?
print(rfModel)

Call:
 randomForest(formula = Exited ~ ., data = training) 
               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 3

          Mean of squared residuals: 0.1042103
                    % Var explained: 35.48

Random Forest evaluation


tableRF <- table(Actual = testing$Exited, Predicted = ifelse(predict(rfModel, testing) > 0.5, 1, 0))
tableRF
      Predicted
Actual    0    1
     0 1914   68
     1  280  238
print(paste('Random Forest Accuracy',sum(diag(tableRF))/sum(tableRF)))
[1] "Random Forest Accuracy 0.8608"
print(paste('Random Forest precision',p3<-tableRF[2,2]/(tableRF[2,2]+tableRF[1,2])))
[1] "Random Forest precision 0.777777777777778"
print(paste('Random Forest recall',r3<-tableRF[2,2]/(tableRF[2,2]+tableRF[2,1])))
[1] "Random Forest recall 0.459459459459459"
print(paste('Random Forest F1Score',2*p3*r3/(r3+p3)))
[1] "Random Forest F1Score 0.577669902912621"

Random Forest Error Rate

plot(rfModel)

Tune Random Forest Model

t <- tuneRF(training[, -10], training[, 10], stepFactor = 0.5, plot = TRUE,
            ntreeTry = 100, trace = TRUE, improve = 0.05)
mtry = 3  OOB error = 3469354985 
Searching left ...
mtry = 6    OOB error = 3517620289 
-0.0139119 0.05 
Searching right ...
mtry = 1    OOB error = 3312994210 
0.04506912 0.05 

Fit the Random Forest Model After Tuning

rfModel_new <- randomForest(Exited ~., data = training, ntree = 100,
                            mtry = 1, importance = TRUE, proximity = TRUE)
The response has five or fewer unique values.  Are you sure you want to do regression?
print(rfModel_new)

Call:
 randomForest(formula = Exited ~ ., data = training, ntree = 100,      mtry = 1, importance = TRUE, proximity = TRUE) 
               Type of random forest: regression
                     Number of trees: 100
No. of variables tried at each split: 1

          Mean of squared residuals: 0.1228001
                    % Var explained: 23.97

Random Forest evaluation After Tuning


tableRFN <- table(Actual = testing$Exited, Predicted = ifelse(predict(rfModel_new, testing) > 0.5, 1, 0))
tableRFN
      Predicted
Actual    0    1
     0 1978    4
     1  440   78
print(paste('New Random Forest Accuracy',sum(diag(tableRFN))/sum(tableRFN)))
[1] "New Random Forest Accuracy 0.8224"
print(paste('New Random Forest precision',p4<-tableRFN[2,2]/(tableRFN[2,2]+tableRFN[1,2])))
[1] "New Random Forest precision 0.951219512195122"
print(paste('New Random Forest recall',r4<-tableRFN[2,2]/(tableRFN[2,2]+tableRFN[2,1])))
[1] "New Random Forest recall 0.150579150579151"
print(paste('New Random Forest F1Score',2*p4*r4/(r4+p4)))
[1] "New Random Forest F1Score 0.26"

Random Forest Feature Importance

varImpPlot(rfModel_new, sort=T, n.var = 5, main = 'Top 5 Feature Importance')

Conclusion

In this project, we use Logistic Regression, Decision Tree and Random Forest to analysis customer churn on this dataset. As a result, if we use precision to evaluate these models, tuned Random Forest model has the best perfermance(0.95); if we use recall to evaluate these models, Random Forest model has the best perfermance(0.46); if we use F1 Score to evaluate these models, Random Forest model and decision tree model have the best perfermance(0.58); if we use accuracy to evaluate these models, Random Forest model and decision tree model has the best perfermance(0.86);

LS0tDQp0aXRsZTogIkJEOCBQcm9qZXQgZGUgU2Vzc2lvbiAtIENodXJuIHByZWRpY3Rpb24iDQphdXRob3I6ICJZaWZlaSBaaGFuZyINCmRhdGU6ICIwNi8xMC8yMDE5Ig0Kb3V0cHV0OiANCiAgaHRtbF9ub3RlYm9vazoNCiAgICB0b2M6IHllcw0KICBodG1sX2RvY3VtZW50Og0KICAgIGhpZ2hsaWdodDogdGV4dG1hdGUNCiAgICB0aGVtZTogc3BhY2VsYWINCiAgICB0b2M6IHllcw0KICBwZGZfZG9jdW1lbnQ6DQogICAgdG9jOiB5ZXMNCiAgd29yZF9kb2N1bWVudDoNCiAgICB0b2M6IHllcw0KYWx3YXlzX2FsbG93X2h0bWw6IHllcw0KLS0tDQoNCmBgYHtyIHNldHVwLCBpbmNsdWRlPUZBTFNFfQ0Ka25pdHI6Om9wdHNfY2h1bmskc2V0KGVjaG8gPSBUUlVFKQ0KYGBgDQoNCiMgSW50cm9kdWN0aW9uDQoNCk5vd2FkYXlzIGNodXJuIHByZWRpY2l0aW9uIGJlY29tZXMgdmVyeSBwb3B1bGFyIGFuZCBpbXBvcnRhbnQgZm9yIG1hbnkgY29tcGFuaWVzIHRvIGFuYWx5c2Ugd2hpY2ggY3VzdG9tZXJzIHdpbGwgc3RvcCB1c2luZyB0aGVpciBzZXZpY2VzIHNvIHRoYXQgdGhleSBjYW4gYWRqdXN0ZSB0aGVpciBidXNpbmVzcyB0byBmaXQgd2VsbCB0aGUgbWFya2V0aW5nLg0KDQpJbiB0aGlzIHByb2plY3QsIGRhdGFzZXQoW0NodXJuX01vZGVsbGluZy5jc3ZdKGh0dHBzOi8vd3d3LmthZ2dsZS5jb20vYWFrYXNoNTA4OTcvY2h1cm4tbW9kZWxsaW5nY3N2KSkgd2lsbCBiZSB1c2VkIHRvIGFuYWx5c2UgY2h1cm4gcHJlZGljdGlvbi4gDQoNCkFuZCB0aGVzZSBmb2xsb3dpbmcgbW9kZWxlcyB3aWxsIGJlIGludHJvZHVjZWQ6DQogMSwgKipMb2dpc3RpYyBSZWdyZXNzaW9uKioNCiAyLCAqKkRlY2lzaW9uIFRyZWUqKg0KIDMsICoqUmFuZG9tIEZvcmVzdCoqDQoNClRoaXMgcmVwb3J0IGlzIGFsc28gYXZhaWxpYmxlIG9ubGluZSwgc2VlIDxodHRwOi8vcnB1YnMuY29tL2p6OC9DaHVybnByZWRpY3Rpb24+Lg0KDQojIERhdGEgUHJvY2Vzc2luZw0KIyMgTG9hZCBsaWJyYXJpZXMNCg0KYGBge3J9DQpsaWJyYXJ5KGdncGxvdDIpDQpsaWJyYXJ5KGdyaWRFeHRyYSkNCmxpYnJhcnkoZ2d0aGVtZXMpDQpsaWJyYXJ5KGNhcmV0KQ0KbGlicmFyeShwbHlyKQ0KbGlicmFyeShjb3JycGxvdCkNCmxpYnJhcnkoTUFTUykNCmxpYnJhcnkocmFuZG9tRm9yZXN0KQ0KbGlicmFyeShwYXJ0eSkNCmxpYnJhcnkoZHBseXIpDQpsaWJyYXJ5KHJwYXJ0KQ0KbGlicmFyeShycGFydC5wbG90KQ0KYGBgDQoNCiMjIExvYWQgZGF0YQ0KYGBge3J9DQpjaHVybiA8LSByZWFkLmNzdignQ2h1cm5fTW9kZWxsaW5nLmNzdicpDQpkaW0oY2h1cm4pDQpgYGANCg0KIyMgUHJlcGFyZSB0aGUgZGF0YXNldA0KDQoNCmBgYHtyfQ0KbmFtZXMoY2h1cm4pDQpgYGANCg0KDQpgYGB7cn0NCnN0cihjaHVybikNCmBgYA0KYGBge3J9DQpoZWFkKGNodXJuKQ0KYGBgDQoNCg0KYGBge3J9DQpzYXBwbHkoY2h1cm4sIGZ1bmN0aW9uKHgpIHN1bShpcy5uYSh4KSkpDQpgYGANCg0KYGBge3J9DQpjaHVybkRhdGEgPC0gY2h1cm4gJT4lICBzZWxlY3QoLWMoUm93TnVtYmVyKSkgJT4lICBzdWJzZXQoIWR1cGxpY2F0ZWQoY2h1cm4kQ3VzdG9tZXJJZCkpDQpkaW0oY2h1cm5EYXRhKQ0KYGBgDQoNCg0KYGBge3J9DQpzdW1tYXJ5KGNodXJuRGF0YSkNCmBgYA0KYGBge3J9DQpjaHVybkRhdGEkSGFzQ3JDYXJkIDwtIGFzLmZhY3RvcihtYXB2YWx1ZXMoY2h1cm5EYXRhJEhhc0NyQ2FyZCwgZnJvbSA9IGMoIjAiLCAiMSIpLCB0byA9IGMoIk5vIiwgIlllcyIpKSkNCmNodXJuRGF0YSRJc0FjdGl2ZU1lbWJlciA8LSBhcy5mYWN0b3IobWFwdmFsdWVzKGNodXJuRGF0YSRJc0FjdGl2ZU1lbWJlciwgZnJvbSA9IGMoIjAiLCAiMSIpLCB0byA9IGMoIk5vIiwgIlllcyIpKSkNCmBgYA0KDQpSZW1vdmUgbm8gdXNlZnVsIHZhcmlhYmxlcy4NCmBgYHtyfQ0KY2h1cm5EYXRhJEN1c3RvbWVySWQgPC0gTlVMTCANCmNodXJuRGF0YSRTdXJuYW1lICA8LSBOVUxMIA0KYGBgDQoNCiMgRXhwbG9yYXRvcnkgZGF0YQ0KIyMgQ29ycmVsYXRpb24gYmV0d2VlbiBudW1lcmljIHZhcmlhYmxlcw0KYGBge3J9DQoNCm51bWVyaWNfdmFyIDwtIHNhcHBseShjaHVybkRhdGEsIGlzLm51bWVyaWMpDQpjb3JyX21hdHJpeCA8LSBjb3IoY2h1cm5EYXRhWywgbnVtZXJpY192YXJdKQ0KY29ycnBsb3QoY29ycl9tYXRyaXgsIG1haW4gPSAiXG5cbkNvcnJlbGF0aW9uIFBsb3QgZm9yIE51bWVyaWNhbCBWYXJpYWJsZXMiLCBtZXRob2QgPSAibnVtYmVyIikNCg0KYGBgDQoNClRoZXNlIHZhcmlhYmxlcyBhcmUgbm90IHZlcnkgY29ycmVsYXRlZCBzbyB0aGF0IHRoZXkgYXJlIGFsbCBrZXB0Lg0KDQpgYGB7cn0NCnN0cihjaHVybkRhdGEpDQpgYGANCg0KYGBge3J9DQpwMSA8LSBnZ3Bsb3QoY2h1cm5EYXRhLCBhZXMoeD1DcmVkaXRTY29yZSApKSArIGdndGl0bGUoIkNyZWRpdCBTY29yZSAiKSArIHhsYWIoIkNyZWRpdCBTY29yZSAiKSArDQogICAgICBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArDQogICAgICB5bGFiKCJQZXJjZW50YWdlIikgKyBjb29yZF9mbGlwKCkgKyB0aGVtZV9taW5pbWFsKCkNCg0KcDIgPC0gZ2dwbG90KGNodXJuRGF0YSwgYWVzKHg9R2VvZ3JhcGh5KSkgKyBnZ3RpdGxlKCJHZW9ncmFwaHkiKSArIA0KICAgICAgeGxhYigiR2VvZ3JhcGh5IikgKyBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArIHlsYWIoIlBlcmNlbnRhZ2UiKSArIGNvb3JkX2ZsaXAoKSArIHRoZW1lX21pbmltYWwoKQ0KDQpwMyA8LSBnZ3Bsb3QoY2h1cm5EYXRhLCBhZXMoeD1HZW5kZXIpKSArIGdndGl0bGUoIkdlbmRlciIpICsgeGxhYigiR2VuZGVyIikgKyANCiAgICAgIGdlb21fYmFyKGFlcyh5ID0gMTAwKiguLmNvdW50Li4pL3N1bSguLmNvdW50Li4pKSwgd2lkdGggPSAwLjUpICsgDQogICAgICB5bGFiKCJQZXJjZW50YWdlIikgKyBjb29yZF9mbGlwKCkgKyB0aGVtZV9taW5pbWFsKCkNCg0KcDQgPC0gZ2dwbG90KGNodXJuRGF0YSwgYWVzKHg9QWdlKSkgKyBnZ3RpdGxlKCJBZ2UiKSArIHhsYWIoIkFnZSIpICsgDQogICAgICBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArIA0KICAgICAgeWxhYigiUGVyY2VudGFnZSIpICsgY29vcmRfZmxpcCgpICsgdGhlbWVfbWluaW1hbCgpDQoNCmdyaWQuYXJyYW5nZShwMSwgcDIsIHAzLCBwNCwgbmNvbD0yKQ0KYGBgDQpgYGB7cn0NCnA1IDwtIGdncGxvdChjaHVybkRhdGEsIGFlcyh4PVRlbnVyZSkpICsgZ2d0aXRsZSgiVGVudXJlIikgKyANCiAgICAgIHhsYWIoIlRlbnVyZSIpICsgDQogICAgICBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArIA0KICAgICAgeWxhYigiUGVyY2VudGFnZSIpICsgY29vcmRfZmxpcCgpICsgdGhlbWVfbWluaW1hbCgpDQpwNiA8LSBnZ3Bsb3QoY2h1cm5EYXRhLCBhZXMoeD1OdW1PZlByb2R1Y3RzICkpICsgZ2d0aXRsZSgiTnVtIE9mIFByb2R1Y3RzICIpICsgDQogICAgICB4bGFiKCJNdWx0aXBsZSBMaW5lcyIpICsgDQogICAgICBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArIA0KICAgICAgeWxhYigiTnVtIE9mIFByb2R1Y3RzICIpICsgY29vcmRfZmxpcCgpICsgdGhlbWVfbWluaW1hbCgpDQoNCnA3IDwtIGdncGxvdChjaHVybkRhdGEsIGFlcyh4PUhhc0NyQ2FyZCkpICsgZ2d0aXRsZSgiSGFzIENyZWRpdCBDYXJkIikgKyANCiAgICAgIHhsYWIoIkhhcyBDcmVkaXQgQ2FyZCIpICsgDQogICAgICBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArIA0KICAgICAgeWxhYigiUGVyY2VudGFnZSIpICsgY29vcmRfZmxpcCgpICsgdGhlbWVfbWluaW1hbCgpDQoNCnA4IDwtIGdncGxvdChjaHVybkRhdGEsIGFlcyh4PUlzQWN0aXZlTWVtYmVyKSkgKyBnZ3RpdGxlKCJJcyBBY3RpdmUgTWVtYmVyICIpICsgDQogICAgICB4bGFiKCJJcyBBY3RpdmUgTWVtYmVyICIpICsgDQogICAgICBnZW9tX2JhcihhZXMoeSA9IDEwMCooLi5jb3VudC4uKS9zdW0oLi5jb3VudC4uKSksIHdpZHRoID0gMC41KSArIA0KICAgICAgeWxhYigiUGVyY2VudGFnZSIpICsgY29vcmRfZmxpcCgpICsgdGhlbWVfbWluaW1hbCgpDQoNCmdyaWQuYXJyYW5nZShwNSwgcDYsIHA3LCBwOCwgbmNvbD0yKQ0KYGBgDQoNCmBgYHtyfQ0KYm94cGxvdChjaHVybkRhdGEkRXN0aW1hdGVkU2FsYXJ5LCBjb2wgPSBncmV5KDAuOSksIG1haW4gPSAiRXN0aW1hdGVkIFNhbGFyeSIsIHhsYWIgPSAiRXN0aW1hdGVkIFNhbGFyeSIseWxhYiA9ICJFZmZlY3RpZiIpDQphYmxpbmUoaCA9IG1lZGlhbihjaHVybkRhdGEkRXN0aW1hdGVkU2FsYXJ5LCBuYS5ybSA9IFRSVUUpLCBjb2wgPSAibmF2eSIsIGx0eSA9IDIpDQp0ZXh0KDEuMzUsIG1lZGlhbihjaHVybkRhdGEkRXN0aW1hdGVkU2FsYXJ5LCBuYS5ybSA9IFRSVUUpICsgMC4xNSwgIk3DqWRpYW5lIiwgY29sID0gIm5hdnkiKQ0KUTEgPC0gcXVhbnRpbGUoY2h1cm5EYXRhJEVzdGltYXRlZFNhbGFyeSwgcHJvYnMgPSAwLjI1LCBuYS5ybSA9IFRSVUUpDQphYmxpbmUoaCA9IFExLCBjb2wgPSAiZGFya3JlZCIpDQp0ZXh0KDEuMzUsIFExICsgMC4xNSwgIlExIDogcHJlbWllciBxdWFydGlsZSIsIGNvbCA9ICJkYXJrcmVkIiwgbHR5ID0gMikNClEzIDwtIHF1YW50aWxlKGNodXJuRGF0YSRFc3RpbWF0ZWRTYWxhcnksIHByb2JzID0gMC43NSwgbmEucm0gPSBUUlVFKQ0KYWJsaW5lKGggPSBRMywgY29sID0gImRhcmtyZWQiKQ0KdGV4dCgxLjM1LCBRMyArIDAuMTUsICJRMyA6IHRyb2lzacOobWUgcXVhcnRpbGUiLCBjb2wgPSAiZGFya3JlZCIsIGx0eSA9IDIpDQphcnJvd3MoeDAgPSAwLjcsIHkwID0gcXVhbnRpbGUoY2h1cm5EYXRhJEVzdGltYXRlZFNhbGFyeSwgcHJvYnMgPSAwLjc1LA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIG5hLnJtID0gVFJVRSksIHgxID0gMC43LCB5MSA9IHF1YW50aWxlKGNodXJuRGF0YSRFc3RpbWF0ZWRTYWxhcnksDQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgcHJvYnMgPSAwLjI1LCBuYS5ybSA9IFRSVUUpLCBsZW5ndGggPSAwLjEsIGNvZGUgPSAzKQ0KdGV4dCgwLjcsIFExICsgKFEzIC0gUTEpLzIgKyAwLjE1LCAiaCIsIHBvcyA9IDIpDQptdGV4dCgiTCfDqWNhcnQgaW50ZXItcXVhcnRpbGUgaCBpcyBhYm91dCAxMDAwMDAiLCBzaWRlID0gMSkNCmBgYA0KDQoNCg0KIyBNb2RlbGluZw0KIyMgTG9naXN0aWMgUmVncmVzc2lvbg0KDQojIyMgTG9naXN0aWMgUmVncmVzc2lvbiBhbmFseXNlDQpTcGxpdCBkYXRhIGludG8gdHJhaW5pbmcgYW5kIHRlc3Rpbmcgc2V0czoNCmBgYHtyfQ0KdHJhaW5JbmRleCA8LSBjcmVhdGVEYXRhUGFydGl0aW9uKGNodXJuRGF0YSRFeGl0ZWQsIHAgPSAuNzUsIGxpc3QgPSBGQUxTRSwgdGltZXMgPSAxKQ0Kc2V0LnNlZWQoMjAxOSkNCnRyYWluaW5nIDwtIGNodXJuRGF0YVt0cmFpbkluZGV4LCBdDQp0ZXN0aW5nIDwtIGNodXJuRGF0YVstIHRyYWluSW5kZXgsIF0NCmBgYA0KVmlyaWZpeSB0aGUgMiBzZXRzOg0KYGBge3J9DQpkaW0odHJhaW5pbmcpDQpkaW0odGVzdGluZykNCmBgYA0KDQpGaXR0aW5nIHRoZSBMb2dpc3RpYyBSZWdyZXNzaW9uIE1vZGVsOg0KYGBge3J9DQpMb2dNb2RlbCA8LSBnbG0oRXhpdGVkIH4gLiwgZmFtaWx5ID0gYmlub21pYWwobGluayA9ICJsb2dpdCIpLCBkYXRhID0gdHJhaW5pbmcpDQpwcmludChzdW1tYXJ5KExvZ01vZGVsKSkNCmBgYA0KDQpGZWF0dXJlIEFuYWx5c2lzOg0KYGBge3J9DQphbm92YShMb2dNb2RlbCwgdGVzdCA9ICJDaGlzcSIpDQpgYGANCg0KDQoNCiMjIyBMb2dpc3RpYyBSZWdyZXNzaW9uIGV2YWx1YXRpb24NCmBgYHtyfQ0KdGFibGVMUiA8LSB0YWJsZShBY3R1YWw9dGVzdGluZyRFeGl0ZWQsIHByZWRpY3Rpb249Zml0dGVkX3Jlc3VsdHMgPiAwLjUpDQp0YWJsZUxSDQpwcmludChwYXN0ZSgnTG9naXN0aWMgUmVncmVzc2lvbiBBY2N1cmFjeScsc3VtKGRpYWcodGFibGVMUikpL3N1bSh0YWJsZUxSKSkpDQpwcmludChwYXN0ZSgnTG9naXN0aWMgUmVncmVzc2lvbiBwcmVjaXNpb24nLHAxPC10YWJsZUxSWzIsMl0vKHRhYmxlTFJbMiwyXSt0YWJsZUxSWzEsMl0pKSkNCnByaW50KHBhc3RlKCdMb2dpc3RpYyBSZWdyZXNzaW9uIHJlY2FsbCcscjE8LXRhYmxlTFJbMiwyXS8odGFibGVMUlsyLDJdK3RhYmxlTFJbMiwxXSkpKQ0KcHJpbnQocGFzdGUoJ0xvZ2lzdGljIFJlZ3Jlc3Npb24gRjFTY29yZScsMipwMSpyMS8ocjErcDEpKSkNCmBgYA0KDQpPZGRzIFJhdGlvDQoocGVyZm9ybWFuY2UgbWVhc3VyZW1lbnRzIGluIGxvZ2lzdGljIHJlZ3Jlc3Npb24sIHdoYXQgdGhlIG9kZHMgb2YgYW4gZXZlbnQgaXMgaGFwcGVuaW5nLikNCmBgYHtyfQ0KZXhwKGNiaW5kKE9SID0gY29lZihMb2dNb2RlbCksIGNvbmZpbnQoTG9nTW9kZWwpKSkNCmBgYA0KDQojIyBEZWNpc2lvbiBUcmVlDQojIyMgRGVjaXNpb24gVHJlZSBhbmFseXNlDQoNCg0KYGBge3J9DQpzZXQuc2VlZCg5MSkNCkRUcmVlIDwtIHJwYXJ0KEV4aXRlZCB+IC4sIHRyYWluaW5nLCBtZXRob2Q9ImNsYXNzIikNCnBsb3RjcChEVHJlZSkNCmBgYA0KYGBge3J9DQpwcnAoRFRyZWUsZXh0cmE9MSkNCmBgYA0KDQoNCg0KIyMjIERlY2lzaW9uIFRyZWUgZXZhbHVhdGlvbg0KDQpgYGB7cn0NCiANCnRhYmxlRFQgPC0gdGFibGUoQWN0dWFsID0gdGVzdGluZyRFeGl0ZWQsIFByZWRpY3RlZCA9IHByZWRpY3QoRFRyZWUsIHRlc3RpbmcsIHR5cGU9ImNsYXNzIikpDQp0YWJsZURUDQoNCnByaW50KHBhc3RlKCdEZWNpc2lvbiBUcmVlIEFjY3VyYWN5JyxzdW0oZGlhZyh0YWJsZURUKSkvc3VtKHRhYmxlRFQpKSkNCnByaW50KHBhc3RlKCdEZWNpc2lvbiBUcmVlIHByZWNpc2lvbicscDI8LXRhYmxlRFRbMiwyXS8odGFibGVEVFsyLDJdK3RhYmxlRFRbMSwyXSkpKQ0KcHJpbnQocGFzdGUoJ0RlY2lzaW9uIFRyZWUgcmVjYWxsJyxyMjwtdGFibGVEVFsyLDJdLyh0YWJsZURUWzIsMl0rdGFibGVEVFsyLDFdKSkpDQpwcmludChwYXN0ZSgnRGVjaXNpb24gVHJlZSBGMVNjb3JlJywyKnAyKnIyLyhyMitwMikpKQ0KYGBgDQoNCiMjIFJhbmRvbSBGb3Jlc3QNCiMjIyBSYW5kb20gRm9yZXN0IEluaXRpYWwgTW9kZWwNCg0KYGBge3J9DQpyZk1vZGVsIDwtIHJhbmRvbUZvcmVzdChFeGl0ZWQgfi4sIGRhdGEgPSB0cmFpbmluZykNCnByaW50KHJmTW9kZWwpDQpgYGANCg0KIyMjIFJhbmRvbSBGb3Jlc3QgZXZhbHVhdGlvbg0KYGBge3J9DQoNCnRhYmxlUkYgPC0gdGFibGUoQWN0dWFsID0gdGVzdGluZyRFeGl0ZWQsIFByZWRpY3RlZCA9IGlmZWxzZShwcmVkaWN0KHJmTW9kZWwsIHRlc3RpbmcpID4gMC41LCAxLCAwKSkNCnRhYmxlUkYNCnByaW50KHBhc3RlKCdSYW5kb20gRm9yZXN0IEFjY3VyYWN5JyxzdW0oZGlhZyh0YWJsZVJGKSkvc3VtKHRhYmxlUkYpKSkNCnByaW50KHBhc3RlKCdSYW5kb20gRm9yZXN0IHByZWNpc2lvbicscDM8LXRhYmxlUkZbMiwyXS8odGFibGVSRlsyLDJdK3RhYmxlUkZbMSwyXSkpKQ0KcHJpbnQocGFzdGUoJ1JhbmRvbSBGb3Jlc3QgcmVjYWxsJyxyMzwtdGFibGVSRlsyLDJdLyh0YWJsZVJGWzIsMl0rdGFibGVSRlsyLDFdKSkpDQpwcmludChwYXN0ZSgnUmFuZG9tIEZvcmVzdCBGMVNjb3JlJywyKnAzKnIzLyhyMytwMykpKQ0KYGBgDQoNCiMjIyBSYW5kb20gRm9yZXN0IEVycm9yIFJhdGUNCg0KYGBge3J9DQpwbG90KHJmTW9kZWwpDQpgYGANCiMjIyBUdW5lIFJhbmRvbSBGb3Jlc3QgTW9kZWwNCmBgYHtyfQ0KdCA8LSB0dW5lUkYodHJhaW5pbmdbLCAtMTBdLCB0cmFpbmluZ1ssIDEwXSwgc3RlcEZhY3RvciA9IDAuNSwgcGxvdCA9IFRSVUUsDQogICAgICAgICAgICBudHJlZVRyeSA9IDEwMCwgdHJhY2UgPSBUUlVFLCBpbXByb3ZlID0gMC4wNSkNCmBgYA0KDQojIyMgRml0IHRoZSBSYW5kb20gRm9yZXN0IE1vZGVsIEFmdGVyIFR1bmluZw0KDQpgYGB7cn0NCnJmTW9kZWxfbmV3IDwtIHJhbmRvbUZvcmVzdChFeGl0ZWQgfi4sIGRhdGEgPSB0cmFpbmluZywgbnRyZWUgPSAxMDAsDQogICAgICAgICAgICAgICAgICAgICAgICAgICAgbXRyeSA9IDEsIGltcG9ydGFuY2UgPSBUUlVFLCBwcm94aW1pdHkgPSBUUlVFKQ0KcHJpbnQocmZNb2RlbF9uZXcpDQpgYGANCg0KIyMjIFJhbmRvbSBGb3Jlc3QgZXZhbHVhdGlvbiBBZnRlciBUdW5pbmcNCg0KYGBge3J9DQoNCnRhYmxlUkZOIDwtIHRhYmxlKEFjdHVhbCA9IHRlc3RpbmckRXhpdGVkLCBQcmVkaWN0ZWQgPSBpZmVsc2UocHJlZGljdChyZk1vZGVsX25ldywgdGVzdGluZykgPiAwLjUsIDEsIDApKQ0KdGFibGVSRk4NCnByaW50KHBhc3RlKCdOZXcgUmFuZG9tIEZvcmVzdCBBY2N1cmFjeScsc3VtKGRpYWcodGFibGVSRk4pKS9zdW0odGFibGVSRk4pKSkNCnByaW50KHBhc3RlKCdOZXcgUmFuZG9tIEZvcmVzdCBwcmVjaXNpb24nLHA0PC10YWJsZVJGTlsyLDJdLyh0YWJsZVJGTlsyLDJdK3RhYmxlUkZOWzEsMl0pKSkNCnByaW50KHBhc3RlKCdOZXcgUmFuZG9tIEZvcmVzdCByZWNhbGwnLHI0PC10YWJsZVJGTlsyLDJdLyh0YWJsZVJGTlsyLDJdK3RhYmxlUkZOWzIsMV0pKSkNCnByaW50KHBhc3RlKCdOZXcgUmFuZG9tIEZvcmVzdCBGMVNjb3JlJywyKnA0KnI0LyhyNCtwNCkpKQ0KYGBgDQoNCiMjIyBSYW5kb20gRm9yZXN0IEZlYXR1cmUgSW1wb3J0YW5jZQ0KDQpgYGB7cn0NCnZhckltcFBsb3QocmZNb2RlbF9uZXcsIHNvcnQ9VCwgbi52YXIgPSA1LCBtYWluID0gJ1RvcCA1IEZlYXR1cmUgSW1wb3J0YW5jZScpDQpgYGANCiMgQ29uY2x1c2lvbg0KDQpJbiB0aGlzIHByb2plY3QsIHdlIHVzZSBMb2dpc3RpYyBSZWdyZXNzaW9uLCBEZWNpc2lvbiBUcmVlIGFuZCBSYW5kb20gRm9yZXN0IHRvIGFuYWx5c2lzIGN1c3RvbWVyIGNodXJuIG9uIHRoaXMgZGF0YXNldC4gDQpBcyBhIHJlc3VsdCwgDQppZiB3ZSB1c2UgcHJlY2lzaW9uIHRvIGV2YWx1YXRlIHRoZXNlIG1vZGVscywgdHVuZWQgIFJhbmRvbSBGb3Jlc3QgbW9kZWwgaGFzIHRoZSBiZXN0IHBlcmZlcm1hbmNlKDAuOTUpOw0KaWYgd2UgdXNlIHJlY2FsbCB0byBldmFsdWF0ZSB0aGVzZSBtb2RlbHMsICBSYW5kb20gRm9yZXN0IG1vZGVsIGhhcyB0aGUgYmVzdCBwZXJmZXJtYW5jZSgwLjQ2KTsNCmlmIHdlIHVzZSBGMSBTY29yZSB0byBldmFsdWF0ZSB0aGVzZSBtb2RlbHMsICBSYW5kb20gRm9yZXN0IG1vZGVsIGFuZCBkZWNpc2lvbiB0cmVlIG1vZGVsIGhhdmUgdGhlIGJlc3QgcGVyZmVybWFuY2UoMC41OCk7IA0KaWYgd2UgdXNlIGFjY3VyYWN5ICB0byBldmFsdWF0ZSB0aGVzZSBtb2RlbHMsIFJhbmRvbSBGb3Jlc3QgbW9kZWwgYW5kIGRlY2lzaW9uIHRyZWUgbW9kZWwgaGFzIHRoZSBiZXN0IHBlcmZlcm1hbmNlKDAuODYpOyANCg==