본문 바로가기

팀 프로젝트/비즈니스 모델링2

[2025.12.13] 비즈니스모델링2 최종 결과 보고서

평가 방식

Final Report (최종레포트)는 여러분이 진행한 데이터분석/예측모델링 (Data Anlytics/Predictive Modeling) 프로젝트 전체를 잘 정리하는 보고서입니다. 지난 1학기부터 배워왔던 것을 정리하는 프로젝트인 만큼, 수업시간에 배운 것들을 잘 활용하여 프로젝트를 수행한 과정 및 결과를 보고서에 잘 담아보기 바랍니다. 보고서는 최대한 프로페셔널하게 작성하기 바라고, 각 챕터(섹션)별로 넘버링을 적절히 사용하여 가독성 있는 보고서가 될수있도록 하기 바랍니다.

 

레포트와 별도로 여러분이 사용한 원본 및 가공데이터와 코드파일까지 채점시 조교의 재실행이 가능하도록 프로젝트 폴더에 잘 organize를 하여 제출하기 바라고, 혹시 필요한 경우 보고서에 실행 방법 혹은 실행 매뉴얼을 함께 실어주기 바랍니다. (데이터사이즈가 너무 커 제출이 힘들 경우는 별도로 나에게 연락하여 이메일등을 통해 분할해서 받을수도 있으니 상의할 부분 있으면 연락 바랍니다.)

 

이대로 모든 항목들이 하나하나 빠짐없이 꼭 분리된 카테고리로 담길 필요는 없겠습니다만, 여러분들이 수행하는 프로젝트와 보고서작성에 도움이 되고자, 어떠한 내용들이 담기면 좋을지의 대략적인 가이드라인을 제공하면 다음과 같습니다. 

 

* 여러분이 제출한 제안서 (혹은 토픽이 바뀌었다면, 바뀐 토픽에 대한 제안서)에 담긴 프로젝트 토픽에 대한 배경설명 및 목적, 

* 그 토픽에 관련된 데이터분석 및 모델링을 진행하기 위해 어떤 데이터를 어디에서 수집/사용하였는지, 

* 그 데이터는 어떠한 통계적 특성을 가지고 있는지 (데이터 레코드의 개수(데이터 사이즈), 어떤 Column(변수)들이 있는지, 각 변수의 평균, 최소, 최대값, 분산/표준편차 등의 통계적특성은 어떠한지를 포함 한 여러 데이터의 특징들을 수치 혹은 Visualization 등을 통해 분석/설명), 

* 관련하여, 데이터를 통계적으로 분석하여 그 결과 어떠한 유용한 결과도출이 가능했는지 (이는 지난 1학기 마지막 프로젝트과제를 통해 진행하였던 성격의 분석이 되겠습니다. 예를 들어, 이전 인구데이터를 예로들면 성별, 나이, 직업 등의 서로 다른 카테고리별 통계적 특성 분석을 했던 것과 같은 의미있는 통계분석 결과가 도출되었다면 이에 대한 설명) 

* (이제 데이터의 통계적분석에서 '예측모델링'으로 넘어가서,) 이 데이터를 사용하여 Predictive Modeling을 통해 어떤 예측이 가능하고 그것이 어떠한 도움이 될지 (제안서에서 설명하였겠지만, 모델링을 함에 있어 모델링의 목적과 contribution을 설명),

* 이를 위해, 어떠한 데이터마이닝(기계학습) 방법론이 사용되었는지와 그 선택의 이유,

* 방법론에 따라 어떠한 것이 예측이 되어지는 '결과변수'가 되고, 이를 위해 어떠한 '예측변수'들이 활용될 수 있는지 혹은 어떠한 변수들이 어떠한 식으로 모델링에 활용이 되었는지,

* 논의 되어진 모델링을 위한 전처리 과정에서 어떠한 것들이 고려되었는지, (결측치, 이상치, (supervised learning의 경우) training/validation/testing 등으로 어떻게 나누었는지, 어떠한 변수가 활용되었는지와 근거, dimenaionality reduction 등)

* 모델링의 결과 및 정확도 측정

* 결과에 기반하여 이 분석 및 모델링이 시사하는 바(결과가 실제 어떻게 활용되면 좋을지)의 정리

* 프로젝트를 통하여 느낀 점

* Appendix(부록): 여러분이 레포트의 주내용에 포함시키지 않았으나 함께 제출하고 싶은 그래프나 자료가 있으면 제출하여도 되고, 혹시 참고할 부분들을 본문에 넣기 애매할 경우 부록을 사용하여도 됩니다. 또한 프로젝트를 진행하기 위해 작성한 코드에 대한 정보나 실행 매뉴얼이 들어가도 됩니다. 여러분의 용도에 맞게 적절히 사용하기 바랍니다.

* 여러분 팀의 프로젝트가 제대로 구현된 결과인지를 다시 코드를 통해 채점하게 될 예정이니, 모든 코드를 빠짐없이 제출하기 바랍니다. 혹시 코드에 문제가 있을 경우 큰 감점이 있을 수 있으니 코드에 하자가 없도록 빠짐없이 점검하고, 혹시 코드의 수행방법에 있어 매뉴얼(혹은 설명)이 필요한 경우 그 역시 함께 제출 바랍니다.

 


결과 레포트

 


R 코드 및 데이터셋

# -------------------------------------------------------------------------
# [비즈니스모델링2 팀1] 최종 프로젝트 R 코드
# 주제: 공공자전거-대중교통 연계 수요 예측 및 거치대 최적화 방안 연구
# -------------------------------------------------------------------------

# -------------------------------------------------------------------------
# [설치 가이드] 
# ※ 최초 1회만 아래 주석(#)을 풀고 패키지를 설치해주세요.
# -------------------------------------------------------------------------
# install.packages(c("data.table", "dplyr", "lubridate", "tidyr", "sf", 
#                    "ggplot2", "xgboost", "stringr", "zoo", 
#                    "readxl", "readr", "corrplot", "ggcorrplot",
#                    "shiny", "shinydashboard", "shinyWidgets",
#                    "DT", "leaflet", "plotly"))

# -------------------------------------------------------------------------
### 단계 1: 라이브러리 로드
# -------------------------------------------------------------------------
library(data.table)
library(dplyr)
library(lubridate)
library(tidyr)
library(sf)
library(ggplot2)
library(xgboost)
library(stringr)
library(zoo)
library(readxl)
library(readr)
library(corrplot)
library(ggcorrplot)
library(shiny)
library(shinydashboard)
library(shinyWidgets)
library(DT)
library(leaflet)
library(plotly)

# -------------------------------------------------------------------------
### 단계 2: 데이터 로드
# -------------------------------------------------------------------------
# [주의] 아래 경로를 본인의 데이터 폴더 경로로 변경해야 합니다.
FILE_PATH <- "C:/Users/tpgus/OneDrive/Desktop/대학교/4-2/비즈니스모델링2/프로젝트/"

print(">>> [1/7] 자전거 수요 데이터 로드 중...")
bike_log <- read_csv(
  file = paste0(FILE_PATH, "서울특별시 공공자전거 이용정보(시간대별)_202412.csv"), 
  locale = locale(encoding = "CP949"),
  show_col_types = FALSE
)

print(">>> [2/7] 자전거 대여소 정보(마스터) 로드 중...")
bike_master_raw <- read_excel(
  path = paste0(FILE_PATH, "공공자전거 대여소 정보(24.12월 기준).xlsx"), 
  sheet = "대여소현황",
  skip = 5,        
  col_names = FALSE 
)

print(">>> [3/7] 지하철 승하차 데이터 로드 중...")
subway_log_all <- read_csv(
  file = paste0(FILE_PATH, "서울시 지하철 호선별 역별 시간대별 승하차 인원 정보.csv"),
  locale = locale(encoding = "CP949"),
  show_col_types = FALSE
)

print(">>> [4/7] 버스 승하차 데이터 로드 중...")
bus_log <- read_csv(
  file = paste0(FILE_PATH, "2024년_버스노선별_정류장별_시간대별_승하차_인원_정보(12월).csv"),
  locale = locale(encoding = "CP949"),
  show_col_types = FALSE
)

print(">>> [5/7] 기상 데이터 로드 중...")
weather_log <- read_csv(
  file = paste0(FILE_PATH, "시간별 기상 관측 데이터_2024년_12월.csv"),
  locale = locale(encoding = "CP949"),
  show_col_types = FALSE
)

print(">>> [6/7] 지하철 역사 정보 로드 중...")
subway_master <- read_csv(
  file = paste0(FILE_PATH, "서울시 역사마스터 정보.csv"),
  locale = locale(encoding = "CP949"),
  show_col_types = FALSE
)

print(">>> [7/7] 버스 정류소 정보 로드 중...")
bus_master <- read_excel(
  path = paste0(FILE_PATH, "서울시버스정류소위치정보(20241209).xlsx"),
  sheet = "Data"
)

print("===== 모든 데이터 로드 완료 =====")

# -------------------------------------------------------------------------
### 단계 3: 데이터 전처리 및 집계
# -------------------------------------------------------------------------

# 1. 공공자전거 (시간대별/대여소별 총 대여량 집계)
bike_demand <- bike_log %>%
  mutate(
    date = ymd(`대여일자`), 
    hour = `대여시간`,      
    station_id = as.integer(str_extract(`대여소번호`, "\\d+"))
  ) %>%
  rename(bike_count_split = `이용건수`) %>% 
  filter(!is.na(station_id)) %>%
  group_by(station_id, date, hour) %>%
  summarise(bike_demand = sum(bike_count_split, na.rm = TRUE), .groups = 'drop') %>%
  select(station_id, date, hour, bike_demand)

# 2. 지하철
subway_agg <- subway_log_all %>%
  filter(사용월 == 202412) %>%
  pivot_longer(
    cols = ends_with("인원"),
    names_to = c("시간", "구분"),
    names_pattern = "(\\d{2}시-\\d{2}시) (.*인원)",
    values_to = "passengers"
  ) %>%
  mutate(
    hour = as.integer(substr(시간, 1, 2)),
    type = ifelse(grepl("승차", 구분), "geton", "getoff")
  ) %>%
  mutate(passengers_daily_avg = passengers / 31) %>% 
  group_by(station_name = 지하철역, hour, type) %>%
  summarise(passengers = sum(passengers_daily_avg, na.rm = TRUE), .groups = 'drop') %>%
  pivot_wider(
    names_from = type,
    values_from = passengers,
    values_fill = 0
  ) %>%
  rename(subway_geton = geton, subway_getoff = getoff)

# 3. 버스 (시간대별 승하차 집계)
bus_agg <- bus_log %>%
  pivot_longer(
    cols = matches("^\\d{1,2}시"), 
    names_to = c("시간", "구분"),
    names_pattern = "(\\d{1,2}시)(.*총승객수)",
    values_to = "passengers"
  ) %>%
  mutate(
    hour = as.integer(str_remove(시간, "시")),
    type = ifelse(grepl("승차", 구분), "geton", "getoff")
  ) %>%
  mutate(passengers_daily_avg = passengers / 31) %>% 
  mutate(station_id_bus_char = as.character(버스정류장ARS번호)) %>%
  group_by(station_id_bus = station_id_bus_char, hour, type) %>% 
  summarise(passengers = sum(passengers_daily_avg, na.rm = TRUE), .groups = 'drop') %>%
  pivot_wider(
    names_from = type,
    values_from = passengers,
    values_fill = 0
  ) %>%
  rename(bus_geton = geton, bus_getoff = getoff)

# 4. 날씨 데이터 정리
weather_agg <- weather_log %>%
  mutate(
    date = as.Date(일시),
    hour = hour(일시)
  ) %>%
  select(
    date, hour,
    temp = `기온(°C)`,
    precip = `강수량(mm)`,
    wind = `풍속(m/s)`
  ) %>%
  mutate(precip = ifelse(is.na(precip), 0, precip)) %>%
  mutate(hour = ifelse(hour == 24, 0, hour))

# -------------------------------------------------------------------------
### 단계 4: 공간 데이터 결합 (Spatial Join)
# -------------------------------------------------------------------------

crs_UTM_K <- 5179 

# (V) 자전거 마스터 정리
colnames(bike_master_raw) <- c("station_id", "station_name_txt", "gu", "address", "lat", "lon", "install_date", "lcd_count", "qr_count", "op_type")

bike_master_clean <- bike_master_raw %>%
  mutate(
    station_id = as.integer(station_id),
    lat = as.numeric(lat),
    lon = as.numeric(lon),
    cnt_lcd = as.numeric(ifelse(is.na(lcd_count), 0, lcd_count)),
    cnt_qr  = as.numeric(ifelse(is.na(qr_count), 0, qr_count)),
    rack_count = cnt_lcd + cnt_qr
  ) %>%
  select(station_id, station_name_txt, lat, lon, rack_count) %>%
  filter(!is.na(lat) & !is.na(lon) & lat != "")

# 공간 객체(sf)로 변환
bike_sf <- st_as_sf(bike_master_clean, coords = c("lon", "lat"), crs = 4326) %>% st_transform(crs_UTM_K) 
subway_sf <- st_as_sf(subway_master, coords = c("경도", "위도"), crs = 4326) %>% st_transform(crs_UTM_K)
bus_sf <- st_as_sf(bus_master, coords = c("X좌표", "Y좌표"), crs = 4326) %>% st_transform(crs_UTM_K)

# 공간 조인
nearest_subway_idx <- st_nearest_feature(bike_sf, subway_sf)
bike_to_subway <- data.frame(
  station_id = bike_sf$station_id,
  station_name = subway_sf[nearest_subway_idx, ]$역사명
)

nearest_bus_idx <- st_nearest_feature(bike_sf, bus_sf)
bike_to_bus <- data.frame(
  station_id = bike_sf$station_id,
  station_id_bus = as.character(bus_sf[nearest_bus_idx, ]$ARS_ID)
)

# -------------------------------------------------------------------------
### 단계 5: 최종 학습 데이터셋(Master Table) 구축
# -------------------------------------------------------------------------

all_stations <- unique(bike_demand$station_id)
all_dates <- seq(as.Date("2024-12-01"), as.Date("2024-12-31"), by = "day")
all_hours <- 0:23
master_df <- CJ(station_id = all_stations, date = all_dates, hour = all_hours)

master_df <- left_join(master_df, bike_demand, by = c("station_id", "date", "hour"))
master_df <- left_join(master_df, bike_to_subway, by = "station_id")
master_df <- left_join(master_df, bike_to_bus, by = "station_id")
master_df <- left_join(master_df, subway_agg, by = c("station_name", "hour"))
master_df <- left_join(master_df, bus_agg, by = c("station_id_bus" = "station_id_bus", "hour"))
master_df <- left_join(master_df, weather_agg, by = c("date", "hour"))

master_df <- master_df %>%
  mutate(
    bike_demand = ifelse(is.na(bike_demand), 0, bike_demand),
    subway_geton = ifelse(is.na(subway_geton), 0, subway_geton),
    subway_getoff = ifelse(is.na(subway_getoff), 0, subway_getoff),
    bus_geton = ifelse(is.na(bus_geton), 0, bus_geton),    
    bus_getoff = ifelse(is.na(bus_getoff), 0, bus_getoff) 
  ) %>%
  fill(temp, wind, .direction = "downup") %>%
  mutate(precip = ifelse(is.na(precip), 0, precip))

master_df <- master_df %>%
  arrange(station_id, date, hour) %>%
  group_by(station_id, date) %>%
  mutate(
    `t-1_subway_getoff` = lag(subway_getoff, 1, default = 0),
    `t-1_bus_getoff` = lag(bus_getoff, 1, default = 0),
    day_of_week = wday(date, label = TRUE, week_start = 1)
  ) %>%
  ungroup() %>%
  select(-station_name, -station_id_bus) %>%
  na.omit()

# -------------------------------------------------------------------------
### 단계 3.5: 통계적 특성 및 상관관계 분석
# -------------------------------------------------------------------------
print("===== 데이터 기초 통계량 =====")
print(summary(master_df$bike_demand))

print("===== 상관관계 분석 (자전거 수요 vs 1시간 전 지하철 하차) =====")
cor_val <- cor(master_df$bike_demand, master_df$`t-1_subway_getoff`)
print(paste("상관계수:", round(cor_val, 4)))

# -------------------------------------------------------------------------
### 단계 6: 머신러닝 모델 구축 (XGBoost)
# -------------------------------------------------------------------------

train_data <- master_df %>% filter(date <= as.Date("2024-12-24"))
test_data <- master_df %>% filter(date > as.Date("2024-12-24"))

train_features <- train_data %>% 
  select(-bike_demand, -date) %>% 
  mutate(day_of_week = as.integer(day_of_week)) %>%
  as.matrix()
train_label <- train_data$bike_demand
dtrain <- xgb.DMatrix(data = train_features, label = train_label)

test_features <- test_data %>% 
  select(-bike_demand, -date) %>% 
  mutate(day_of_week = as.integer(day_of_week)) %>%
  as.matrix()
test_label <- test_data$bike_demand
dtest <- xgb.DMatrix(data = test_features, label = test_label)

params <- list(
  objective = "reg:squarederror", 
  eval_metric = "rmse",           
  eta = 0.1,                      
  max_depth = 6,
  nthread = -1                    
)

print(">>> XGBoost 모델 학습 시작... (잠시만 기다려주세요)")
xgb_model <- xgb.train(
  params = params,
  data = dtrain,
  nrounds = 200,                
  evals = list(test = dtest),   
  early_stopping_rounds = 20,     
  verbose = 1   
)

importance <- xgb.importance(model = xgb_model)
predictions <- predict(xgb_model, dtest)
rmse <- sqrt(mean((test_label - predictions)^2))
print(paste("===== 최종 모델 성능 (RMSE):", round(rmse, 2), "====="))

# -------------------------------------------------------------------------
### 단계 7: Shiny Dashboard
# -------------------------------------------------------------------------

dow_levels <- (function(x){
  raw <- unique(as.character(x))
  cand_kr <- c("월","화","수","목","금","토","일")
  if (all(cand_kr %in% raw)) return(cand_kr)
  sort(raw)
})(master_df$day_of_week)

STATION_ALL <- "__ALL__"

station_info_map <- bike_master_clean %>% 
  select(station_id, station_name_txt, rack_count) %>% 
  distinct()

ui <- dashboardPage(
  dashboardHeader(title = "Team 1: 따릉이 최적화"),
  
  dashboardSidebar(
    dateRangeInput("date_rng", "기간", start = min(master_df$date), end = max(master_df$date)),
    sliderInput("hour_rng", "시간대", min = 0, max = 23, value = c(6, 22)),
    pickerInput("dow", "요일", choices = dow_levels, selected = dow_levels, multiple = TRUE, options = list(`actions-box` = TRUE)),
    pickerInput("station_pick", "대여소 선택", choices = c("전체" = STATION_ALL, as.character(sort(unique(master_df$station_id)))), selected = STATION_ALL, multiple = FALSE, options = list(`live-search` = TRUE)),
    tags$hr(),
    
    sidebarMenu(
      id = "tabs",  
      menuItem("수요 분석 (Analysis)", tabName = "analysis", icon = icon("chart-line")),
      menuItem("거치대 최적화 (Optimization)", tabName = "optimization", icon = icon("cogs"))
    )
  ),
  
  dashboardBody(
    tags$head(tags$style(HTML(".small-box { min-height: 110px; }"))),
    tabItems(
      # [탭 1] 수요 분석
      tabItem(tabName = "analysis",
              fluidRow(
                valueBoxOutput("kpi_total", width=3), 
                valueBoxOutput("kpi_avg", width=3), 
                valueBoxOutput("kpi_peak", width=3),
                valueBoxOutput("kpi_rmse", width=3)
              ),
              fluidRow(
                box(title = "시간대별 평균 수요 패턴", width=12, plotOutput("p_hourly", height=300))
              ),
              fluidRow(
                box(title = "변수 중요도 (Feature Importance)", width=6, plotOutput("p_importance", height=300)),
                box(title = "일별 예측 vs 실제 (최근 7일)", width=6, plotOutput("p_daily_pred", height=300))
              )
      ),
      
      # [탭 2] 거치대 최적화
      tabItem(tabName = "optimization",
              fluidRow(
                box(title = "📌 최적화 산출 근거 (Logic)", width = 12, status = "info", solidHeader = TRUE,
                    tags$ul(
                      tags$li(strong("최대 예측 수요 (Peak Demand):"), " 수집된 데이터(대중교통, 날씨, 이용이력)의 패턴을 분석하여 산출된 '시간당 최대 대여 예측값'입니다."),
                      tags$li(strong("권장 거치대수 (Recommended):"), " 피크 시간대에도 자전거가 부족하지 않도록 여유분을 둔 값입니다."),
                      tags$div(style="margin-left: 20px; color: blue;", "공식: Ceiling( 최대 예측 수요 × 안전 마진 계수 )"),
                      tags$li(strong("상태 판단 기준:"), " 권장 거치대수와 현재 거치대수의 차이(Gap)로 판단합니다."),
                      tags$div(style="margin-left: 20px;", 
                               span("🔴 부족: 권장 > 현재 (거치대 추가 필요)", style="color:red; font-weight:bold;"), br(),
                               span("🟠 과다: 권장 < 현재 (거치대 철거/이동 가능)", style="color:orange; font-weight:bold;"), br(),
                               span("🟢 적정: 권장 == 현재", style="color:green; font-weight:bold;")
                      )
                    )
                )
              ),
              fluidRow(
                box(title = "파라미터 설정", width = 12, status = "warning", solidHeader = TRUE,
                    sliderInput("safety_margin", "안전 마진 계수 (Safety Margin)", min = 1.0, max = 3.0, value = 1.5, step = 0.1,
                                helpText("예: 1.5로 설정 시, 최대 예측 수요의 1.5배만큼 거치대를 확보합니다."))
                )
              ),
              fluidRow(
                valueBoxOutput("opt_excess", width = 4),
                valueBoxOutput("opt_lack", width = 4),
                valueBoxOutput("opt_balanced", width = 4)
              ),
              fluidRow(
                box(title = "대여소별 최적화 제안표", width = 12, 
                    DTOutput("opt_table")
                )
              )
      )
    )
  )
)

server <- function(input, output, session){
  
  # 공통 필터링
  filtered <- reactive({
    df <- master_df %>%
      dplyr::filter(date >= input$date_rng[1], date <= input$date_rng[2]) %>%
      dplyr::filter(hour >= input$hour_rng[1], hour <= input$hour_rng[2]) %>%
      dplyr::filter(as.character(day_of_week) %in% input$dow)
    if (!is.null(input$station_pick) && input$station_pick != STATION_ALL) {
      df <- dplyr::filter(df, station_id == as.integer(input$station_pick))
    }
    df
  })
  
  make_features <- function(df) {
    df %>% dplyr::mutate(day_of_week = as.integer(factor(as.character(day_of_week), levels = dow_levels))) %>%
      dplyr::select(-bike_demand, -date) %>% as.matrix()
  }
  
  # [탭 1] 분석 결과 출력
  output$kpi_total <- renderValueBox({
    val <- sum(filtered()$bike_demand, na.rm=T)
    valueBox(format(val, big.mark=","), "선택 기간 총 수요", icon=icon("bicycle"), color="green")
  })
  output$kpi_avg <- renderValueBox({
    val <- mean(filtered()$bike_demand, na.rm=T)
    valueBox(round(val, 2), "시간당 평균 대여", icon=icon("chart-line"), color="aqua")
  })
  output$kpi_peak <- renderValueBox({
    df <- filtered()
    if(nrow(df)==0) return(valueBox("-", "Peak Time", color="olive"))
    pk <- df %>% group_by(hour) %>% summarise(v=sum(bike_demand,na.rm=T)) %>% arrange(desc(v)) %>% slice(1)
    valueBox(paste0(pk$hour, "시"), "피크 시간대", icon=icon("clock"), color="olive")
  })
  output$kpi_rmse <- renderValueBox({
    valueBox(round(rmse, 2), "모델 RMSE (오차)", icon=icon("ruler"), color="purple")
  })
  
  output$p_hourly <- renderPlot({
    df_s <- filtered() %>% group_by(hour) %>% summarise(avg=mean(bike_demand,na.rm=T))
    ggplot(df_s, aes(hour, avg)) + geom_line(color="#0073B7", linewidth=1.2) + geom_point(size=3) +
      labs(x="시간(Hour)", y="평균 대여량") + theme_minimal(base_size=14) +
      scale_x_continuous(breaks=0:23)
  })
  
  output$p_importance <- renderPlot({
    xgb.plot.importance(importance_matrix = importance, top_n = 10)
  })
  
  output$p_daily_pred <- renderPlot({
    recent_dates <- sort(unique(master_df$date), decreasing = TRUE)[1:7]
    test_subset <- master_df %>% filter(date %in% recent_dates)
    
    if (!is.null(input$station_pick) && input$station_pick != STATION_ALL) {
      test_subset <- test_subset %>% filter(station_id == as.integer(input$station_pick))
    }
    
    if(nrow(test_subset)==0) return(NULL)
    
    yhat <- predict(xgb_model, make_features(test_subset))
    plot_df <- test_subset %>% 
      mutate(pred = yhat) %>% 
      group_by(date) %>% 
      summarise(Actual = sum(bike_demand), Predicted = sum(pred)) %>%
      pivot_longer(cols = c(Actual, Predicted), names_to = "Type", values_to = "Value")
    
    ggplot(plot_df, aes(date, Value, color=Type, linetype=Type)) + 
      geom_line(linewidth=1) +
      labs(x=NULL, y="일일 총 대여량") + 
      theme_minimal() + theme(legend.position = "top")
  })
  
  # [탭 2] 최적화 결과 계산 및 출력
  opt_data <- reactive({
    target_df <- filtered()
    if(nrow(target_df) == 0) return(NULL)
    
    # 전체 예측 수행
    preds <- predict(xgb_model, make_features(target_df))
    target_df$pred_demand <- preds
    
    # 대여소별 최대 수요(Peak) 계산
    res <- target_df %>%
      group_by(station_id) %>%
      summarise(
        avg_demand = mean(pred_demand, na.rm=TRUE),
        max_pred_demand = max(pred_demand, na.rm=TRUE), 
        .groups = 'drop'
      )
    
    res <- left_join(res, station_info_map, by="station_id")
    
    # 최적화 로직 적용
    res <- res %>%
      mutate(
        recommended_count = ceiling(max_pred_demand * input$safety_margin),
        recommended_count = ifelse(recommended_count < 2, 2, recommended_count), 
        
        diff = recommended_count - rack_count,
        status = case_when(
          diff > 0 ~ "부족 (증설 필요)",
          diff < 0 ~ "과다 (축소 가능)",
          TRUE ~ "적정"
        )
      ) %>%
      arrange(diff) 
    
    res
  })
  
  output$opt_excess <- renderValueBox({
    df <- opt_data()
    cnt <- sum(df$diff < 0, na.rm=T)
    total_reduce <- sum(abs(df$diff[df$diff < 0]), na.rm=T)
    
    valueBox(
      tags$p(paste0(cnt, "개소 (총 ", total_reduce, "대 감축)"), style = "font-size: 18px; font-weight: bold;"), 
      "공급 과잉 대여소", 
      icon = icon("arrow-down"), 
      color = "orange"
    )
  })
  
  output$opt_lack <- renderValueBox({
    df <- opt_data()
    cnt <- sum(df$diff > 0, na.rm=T)
    total_add <- sum(df$diff[df$diff > 0], na.rm=T)
    
    valueBox(
      tags$p(paste0(cnt, "개소 (총 ", total_add, "대 증설)"), style = "font-size: 18px; font-weight: bold;"), 
      "공급 부족 대여소", 
      icon = icon("arrow-up"), 
      color = "red"
    )
  })
  
  output$opt_balanced <- renderValueBox({
    df <- opt_data()
    cnt <- sum(df$diff == 0, na.rm=T)
    
    valueBox(
      tags$p(paste0(cnt, "개소"), style = "font-size: 30px; font-weight: bold;"), 
      "수급 균형 대여소", 
      icon = icon("check"), 
      color = "green"
    )
  })
  
  output$opt_table <- renderDT({
    df <- opt_data()
    if(is.null(df)) return(NULL)
    
    display_df <- df %>%
      select(
        `대여소ID` = station_id,
        `대여소명` = station_name_txt,
        `현재 거치대수` = rack_count,
        `최대 예측 수요` = max_pred_demand,
        `권장 거치대수` = recommended_count,
        `조정 필요량` = diff,
        `상태` = status
      )
    
    datatable(display_df, options = list(pageLength = 10, scrollX = TRUE)) %>%
      formatRound(columns = c("최대 예측 수요"), digits = 2) %>%
      formatStyle(
        '상태',
        backgroundColor = styleEqual(
          c("부족 (증설 필요)", "과다 (축소 가능)", "적정"), 
          c('#ffcccc', '#ffe5cc', '#ccffcc') 
        )
      )
  })
}

shinyApp(ui, server)

 

아래는 사용된 데이터셋 (xlsx, csv) 압축파일

 

 

https://drive.google.com/file/d/1DCDPjWGaAGRs9tox6YsxoaZBnly3WZlJ/view?usp=sharing

 

데이터셋.zip

 

drive.google.com

 

아래는 결과 보고서 원본

 

비즈니스모델링2_최종 결과 보고서_1조.pdf
4.20MB

 


후기

이번 프로젝트에서도 팀장을 맡아 일정 관리 및 프로젝트 진행 방향성을 잡는 역할을 하였다.

특히 회의 시간에는 인공지능2와 동일하게 사회자 역할을 맡아, 팀원이 제시한 의견을 정리해서 다시 한 번 다른 팀원들에게 전달해주고, 이 의견에 대해 어떻게 생각하는지 물어보며 최대한 모두가 만족할 수 있도록 최선을 다했다. 특히 다른 각자 만들어본 코드가 미흡하더라도 칭찬과 이 코드에서 큰 장점을 언급하며 팀 프로젝트에 도움이 될 만한 부분이 있다면 최대한으로 반영하여 최종 R 코드를 만들 수 있었다. 다른 사람의 의견이 묵살되지 않게 하자.가 내 첫번째 규칙이기도 하니깐.

비록 최종 결과 보고서 작성 회의가 3시간 이상 걸렸지만, 마지막에는 다행히 조금 친해졌는지 개인적인 이야기를 나누고, 다음에 만나면 맛있는거도 사준다고 말하면서 훈훈하게 마무리 되었다.

특히 한분이 세현님이 처음 만났을 때 부터 지금까지도 잘 챙겨주셨어서 수업 때 너무 반가워서 먼저 인사를 걸어주셨다는 말을 듣고 내 대학 생활이 헛되지 않았구나 싶었다.

뭐든 인간관계를 중시한 나에게 이런 사소한 말이 큰 선물로 돌아와서 더욱 기뻤다.

귀찮은 일도 대신 맡아주시고, 저희 조 이끌어 주시느라 고생 많으셨습니다 세현님. 이라는 말을 듣고 솔직히 울컥했지만, 괜히 분위기 이상해질까봐 꾹 참고 오히려 다들 잘 따라와주셔서 정말 감사드립니다! 하고 감사인사를 건넸다.

코드 작성과 보고서 작성이 정말 정말 힘들고 오래걸렸지만, 마지막에 좋은 결과를 낼 수 있어서 좋았다.

역시 나는 팀장 역할을 맡는게 기분이 좋다.

 


2025.12.22 점수 발표

 

 

놀랍게도 프로젝트 15점 만점에 15점으로 만점을 받았다!