library(shiny)
library(randomForest)
## randomForest 4.7-1.2
## Type rfNews() to see new features/changes/bug fixes.
library(caret)
## Loading required package: ggplot2
##
## Attaching package: 'ggplot2'
## The following object is masked from 'package:randomForest':
##
## margin
## Loading required package: lattice
library(dplyr)
##
## Attaching package: 'dplyr'
## The following object is masked from 'package:randomForest':
##
## combine
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(scales)
# Lookup for project type labels
proj_type_labels <- c(
"1" = "Wastewater Treatment",
"2" = "General Industrial",
"3" = "Tank Ring",
"4" = "Refinery",
"5" = "Industrial Process",
"6" = "Utility",
"7" = "Civil and Foundation",
"8" = "Demolition",
"9" = "Piping Replacement",
"10" = "Environmental",
"11" = "Water Treatment",
"12" = "Pipeline"
)
# Load and preprocess data
df <- read.csv("estimatingoverview3.20.25.csv")
df <- df[df$Status %in% c(1, 2), ]
df$ProjectType <- factor(as.character(df$ProjType), levels = names(proj_type_labels), labels = proj_type_labels)
# Save unscaled OH&P %
df$Percentof_raw <- df$Percentof
# Normalize Percentof for ML
df$Percentof <- scale(df$Percentof)
# Balance dataset
set.seed(42)
wins <- df[df$Status == 1, ]
losses <- df[df$Status == 2, ]
min_n <- min(nrow(wins), nrow(losses))
df_bal <- rbind(wins[sample(nrow(wins), min_n), ], losses[sample(nrow(losses), min_n), ])
df_bal <- df_bal[sample(nrow(df_bal)), ]
# Train model
model_data <- df_bal[, c("Amount", "Percentof", "ProjectType")]
model_data$ProjectType <- factor(model_data$ProjectType)
model_status <- as.factor(df_bal$Status)
rf_model <- randomForest(x = model_data, y = model_status, ntree = 200)
proj_type_levels <- levels(model_data$ProjectType)
# UI
ui <- fluidPage(
titlePanel("Win Probability Predictor"),
sidebarLayout(
sidebarPanel(
textInput("amount", "Project Amount ($):", value = comma(1000000)),
actionButton("toggle_ohp_mode", "Switch to $ OH&P"),
conditionalPanel(
condition = "output.ohpMode == 'percent'",
numericInput("percent", "OH&P %:", value = 10, min = 0, max = 100, step = 0.1)
),
conditionalPanel(
condition = "output.ohpMode == 'dollar'",
numericInput("dollar", "OH&P $:", value = 100000, min = 0, step = 1000)
),
selectInput("proj_type", "Project Type:", choices = setNames(proj_type_labels, proj_type_labels)),
actionButton("predict_btn", "Predict")
),
mainPanel(
verbatimTextOutput("prediction_result"),
uiOutput("unmeasured_factors_ui")
)
)
)
# Server
server <- function(input, output, session) {
# Track input mode for OH&P: "percent" or "dollar"
ohp_mode <- reactiveVal("percent")
observeEvent(input$toggle_ohp_mode, {
if (ohp_mode() == "percent") {
ohp_mode("dollar")
updateActionButton(session, "toggle_ohp_mode", label = "Switch to % OH&P")
} else {
ohp_mode("percent")
updateActionButton(session, "toggle_ohp_mode", label = "Switch to $ OH&P")
}
})
# Provide reactive OH&P percent synced with dollar
ohp_percent <- reactive({
if (ohp_mode() == "percent") {
input$percent
} else {
amount <- as.numeric(gsub(",", "", input$amount))
if (is.na(amount) || amount == 0) return(0)
round((input$dollar / amount) * 100, 2)
}
})
# Sync dollar input with percent
observe({
req(ohp_mode() == "dollar")
amount <- as.numeric(gsub(",", "", input$amount))
if (!is.na(amount) && amount > 0 && !is.null(input$dollar)) {
pct <- round((input$dollar / amount) * 100, 2)
updateNumericInput(session, "percent", value = pct)
}
})
# Expose OH&P mode to JS for conditionalPanel
output$ohpMode <- reactive({ ohp_mode() })
outputOptions(output, "ohpMode", suspendWhenHidden = FALSE)
observeEvent(input$predict_btn, {
amount_input <- as.numeric(gsub(",", "", input$amount))
percent_input <- ohp_percent()
new_data <- data.frame(
Amount = amount_input,
Percentof = as.numeric(scale(percent_input, center = attr(df$Percentof, "scaled:center"), scale = attr(df$Percentof, "scaled:scale"))),
ProjectType = factor(input$proj_type, levels = proj_type_levels)
)
pred <- predict(rf_model, new_data, type = "prob")
pred_class <- ifelse(pred[, "1"] > 0.5, "Win", "Loss")
output$prediction_result <- renderPrint({
cat("Predicted Class:", pred_class, "\n")
cat("Probability of Win:", round(pred[, "1"], 4), "\n")
cat("Probability of Loss:", round(pred[, "2"], 4))
})
output$unmeasured_factors_ui <- renderUI({
tagList(
tags$hr(),
h4("Note on Unmeasured Factors"),
helpText("This model is limited to historical data between 2024 and early 2025, and does not measure the following in predictions:"),
tags$ul(
tags$li("Client relationships and prior history"),
tags$li("Competitor bids and number of competitors"),
tags$li("When/if an estimate review was conducted"),
tags$li("How many estimators worked on the estimate"),
tags$li("If a site walk was conducted"),
tags$li("Project duration"),
tags$li("If UCI is a GC or Sub"),
tags$li("Bid quality or proposal narrative strength")
)
)
})
})
}
# Run App
shinyApp(ui = ui, server = server)
## PhantomJS not found. You can install it with webshot::install_phantomjs(). If it is installed, please make sure the phantomjs executable can be found via the PATH variable.
##
## Listening on http://127.0.0.1:4185
