yokoha commited on
Commit
7a4c9be
·
verified ·
1 Parent(s): 4c5a48f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1050 -110
app.py CHANGED
@@ -1,14 +1,27 @@
1
  import streamlit as st
2
  import pandas as pd
3
  import numpy as np
4
- from prophet import Prophet
5
- import plotly.express as px
6
- import plotly.graph_objects as go
7
  import matplotlib.pyplot as plt
 
8
  from datetime import date
9
  from pathlib import Path
10
  import matplotlib.font_manager as fm
11
  import matplotlib as mpl
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  # -------------------------------------------------
14
  # CONFIG ------------------------------------------
@@ -31,6 +44,77 @@ else:
31
 
32
  st.set_page_config(page_title="품목별 가격 예측", page_icon="📈", layout="wide")
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # -------------------------------------------------
35
  # UTILITIES ---------------------------------------
36
  # -------------------------------------------------
@@ -38,7 +122,6 @@ DATE_CANDIDATES = {"date", "ds", "ymd", "날짜", "prce_reg_mm", "etl_ldg_dt"}
38
  ITEM_CANDIDATES = {"item", "품목", "code", "category", "pdlt_nm", "spcs_nm"}
39
  PRICE_CANDIDATES = {"price", "y", "value", "가격", "avrg_prce"}
40
 
41
-
42
  def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
43
  """Standardize column names to date/item/price and deduplicate."""
44
  col_map = {}
@@ -87,7 +170,6 @@ def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
87
 
88
  return df
89
 
90
-
91
  @st.cache_data(show_spinner=False)
92
  def load_data() -> pd.DataFrame:
93
  """Load price data from CSV file."""
@@ -142,102 +224,910 @@ def load_data() -> pd.DataFrame:
142
  return df
143
  except Exception as e:
144
  st.error(f"데이터 로드 중 오류 발생: {str(e)}")
145
- # 오류 상세 정보 표시
146
  import traceback
147
  st.code(traceback.format_exc())
148
  st.stop()
149
 
150
-
151
  @st.cache_data(show_spinner=False)
152
  def get_items(df: pd.DataFrame):
153
  return sorted(df["item"].unique())
154
 
 
 
 
 
 
 
 
 
 
155
 
 
 
 
156
  @st.cache_data(show_spinner=False, ttl=3600)
157
- def fit_prophet(df: pd.DataFrame, horizon_end: str, monthly=False, changepoint_prior_scale=0.05):
158
- """
159
- Prophet 모델�� 학습시키고 예측합니다.
160
-
161
- Args:
162
- df: 학습 데이터 (date, price 컬럼 필요)
163
- horizon_end: 예측 종료일
164
- monthly: 월 단위 예측 여부
165
- changepoint_prior_scale: 변화점 민감도 (낮을수록 과적합 감소)
166
- """
167
- # Make a copy and ensure we have data
168
- df = df.copy()
169
- df = df.dropna(subset=["date", "price"])
170
 
171
- # 이상치 제거 (99 퍼센타일 초과 가격 제외)
172
- upper_limit = df["price"].quantile(0.99)
173
- df = df[df["price"] <= upper_limit]
174
 
175
- # 중복 날짜 처리
176
- if monthly:
177
- # 단위로 집계
178
- df["year_month"] = df["date"].dt.strftime('%Y-%m')
179
- df = df.groupby("year_month").agg({"date": "first", "price": "mean"}).reset_index(drop=True)
180
- else:
181
- # 일 단위로 집계
182
- df = df.groupby("date")["price"].mean().reset_index()
183
 
184
- if len(df) < 2:
185
- st.warning(f"데이터 포인트가 부족합니다. 예측을 위해서는 최소 2개 이상의 유효 데이터가 필요합니다. (현재 {len(df)}개)")
186
- return None, None
 
 
 
 
187
 
188
- # Convert to Prophet format
189
- prophet_df = df.rename(columns={"date": "ds", "price": "y"})
190
 
 
191
  try:
192
- # Fit the model with tuned parameters
193
- m = Prophet(
194
- yearly_seasonality=True,
195
- weekly_seasonality=False,
196
- daily_seasonality=False,
197
- changepoint_prior_scale=changepoint_prior_scale, # 과적합 방지
198
- seasonality_prior_scale=10.0, # 계절성 조정
199
- seasonality_mode='multiplicative' # 곱셈 모드 (가격 데이터에 적합)
200
  )
 
201
 
202
- # 한국 명절 효과 추가 (설날, 추석)
203
- m.add_country_holidays(country_name='South Korea')
 
 
204
 
205
- m.fit(prophet_df)
 
 
 
206
 
207
- # Generate future dates
208
- if monthly:
209
- # 월 단위 예측
210
- future_periods = (pd.Timestamp(horizon_end).year - df["date"].max().year) * 12 + \
211
- (pd.Timestamp(horizon_end).month - df["date"].max().month) + 1
212
- future = m.make_future_dataframe(periods=future_periods, freq='MS') # 월 시작일
213
- future = future.resample('MS', on='ds').first().reset_index() # 중복 제거
214
- else:
215
- # 일 단위 예측
216
- periods = max((pd.Timestamp(horizon_end) - df["date"].max()).days, 1)
217
- future = m.make_future_dataframe(periods=periods, freq="D")
218
 
219
- # Make predictions
220
- forecast = m.predict(future)
 
 
221
 
222
- # 예측값 범위 조정 (음수 예측 방지 및 상한값 설정)
223
- forecast['yhat'] = np.maximum(forecast['yhat'], 0) # 음수 제거
224
- max_historical = prophet_df['y'].max() * 5 # 최대 역사적 가격의 5배로 제한
225
- forecast['yhat'] = np.minimum(forecast['yhat'], max_historical) # 상한값 설정
226
 
227
- return m, forecast
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  except Exception as e:
229
- st.error(f"Prophet 모델 생성 중 오류: {str(e)}")
230
- return None, None
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
- def format_currency(value):
234
- """원화 형식으로 숫자 포맷팅"""
235
- return f"{value:,.0f}원"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
  # -------------------------------------------------
239
- # LOAD DATA ---------------------------------------
240
  # -------------------------------------------------
 
241
  raw_df = load_data()
242
 
243
  if len(raw_df) == 0:
@@ -249,6 +1139,13 @@ selected_item = st.sidebar.selectbox("품목", get_items(raw_df))
249
  current_date = date.today()
250
  st.sidebar.caption(f"오늘: {current_date}")
251
 
 
 
 
 
 
 
 
252
  item_df = raw_df.query("item == @selected_item").copy()
253
  if item_df.empty:
254
  st.error("선택한 품목 데이터 없음")
@@ -283,15 +1180,22 @@ with st.expander("데이터 진단"):
283
 
284
  if len(macro_df) < 2:
285
  st.warning(f"{selected_item}에 대한 데이터가 충분하지 않습니다. 전체 기간 데이터를 표시합니다.")
286
- fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 과거 가격")
 
 
287
  st.plotly_chart(fig, use_container_width=True)
288
  else:
289
  try:
 
 
 
290
  with st.spinner("장기 예측 모델 생성 중..."):
291
- # 월 단위 예측으로 변경
292
- m_macro, fc_macro = fit_prophet(macro_df, MACRO_END, monthly=True, changepoint_prior_scale=0.01)
 
 
293
 
294
- if m_macro is not None and fc_macro is not None:
295
  # 실제 데이터와 예측 데이터 구분
296
  cutoff_date = pd.Timestamp("2025-01-01")
297
 
@@ -309,8 +1213,10 @@ else:
309
  line=dict(color="blue", width=2)
310
  ))
311
 
312
- # 예측 데이터 추가 (2025-2030)
313
  forecast_data = fc_macro[fc_macro["ds"] >= cutoff_date].copy()
 
 
314
  if not forecast_data.empty:
315
  fig.add_trace(go.Scatter(
316
  x=forecast_data["ds"],
@@ -338,6 +1244,9 @@ else:
338
  name="95% 신뢰 구간"
339
  ))
340
 
 
 
 
341
  # 레이아웃 설정
342
  fig.update_layout(
343
  title=f"{selected_item} 장기 가격 예측 (1996-2030)",
@@ -373,11 +1282,17 @@ else:
373
  st.error(f"예측가 계산 오류: {str(e)}")
374
  else:
375
  st.warning("예측 모델을 생성할 수 없습니다.")
376
- fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 과거 가격")
 
 
377
  st.plotly_chart(fig, use_container_width=True)
378
  except Exception as e:
379
  st.error(f"장기 예측 오류 발생: {str(e)}")
380
- fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 과거 가격")
 
 
 
 
381
  st.plotly_chart(fig, use_container_width=True)
382
 
383
  # -------------------------------------------------
@@ -399,15 +1314,19 @@ except Exception as e:
399
 
400
  if len(micro_df) < 2:
401
  st.warning(f"최근 데이터가 충분하지 않습니다.")
402
- fig = px.line(item_df, x="date", y="price", title=f"{selected_item} 최근 가격")
 
 
403
  st.plotly_chart(fig, use_container_width=True)
404
  else:
405
  try:
406
  with st.spinner("단기 예측 모델 생성 중..."):
407
- # 월 단위 예측으로 변경
408
- m_micro, fc_micro = fit_prophet(micro_df, MICRO_END, monthly=True, changepoint_prior_scale=0.05)
 
 
409
 
410
- if m_micro is not None and fc_micro is not None:
411
  # 2024-01-01부터 2026-12-31까지 필터링
412
  start_date = pd.Timestamp("2024-01-01")
413
  end_date = pd.Timestamp("2026-12-31")
@@ -481,6 +1400,9 @@ else:
481
  name="95% 신뢰 구간"
482
  ))
483
 
 
 
 
484
  # 레이아웃 설정
485
  fig.update_layout(
486
  title=f"{selected_item} 월별 단기 예측 (2024-2026)",
@@ -541,36 +1463,54 @@ else:
541
  # -------------------------------------------------
542
  # SEASONALITY & PATTERN ---------------------------
543
  # -------------------------------------------------
544
- with st.expander("📆 시즈널리티 & 패턴 설명"):
545
- if 'm_micro' in locals() and m_micro is not None and 'fc_micro' in locals() and fc_micro is not None:
546
  try:
547
- comp_fig = m_micro.plot_components(fc_micro)
548
- st.pyplot(comp_fig)
549
-
550
- month_season = (fc_micro[["ds", "yearly"]]
551
- .assign(month=lambda d: d.ds.dt.month)
552
- .groupby("month")["yearly"].mean())
553
- st.markdown(
554
- f"**연간 피크 월:** {int(month_season.idxmax())} \n"
555
- f"**연간 저점 월:** {int(month_season.idxmin())}월 \n"
556
- f"**연간 변동폭:** {month_season.max() - month_season.min():.1f}")
557
-
558
- # 월별 계절성 차트
559
- month_names = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"]
560
- month_values = month_season.values
561
-
562
- fig = px.bar(
563
- x=month_names,
564
- y=month_values,
565
- title=f"{selected_item} 월별 가격 변동 패턴",
566
- labels={"x": "월", "y": "상대적 가격 변동"}
567
- )
568
-
569
- st.plotly_chart(fig, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  except Exception as e:
571
- st.error(f"시즈널리티 분석 오류: {str(e)}")
572
- else:
573
- st.info("패턴 분석을 위한 충분한 데이터가 없습니다.")
574
 
575
  # -------------------------------------------------
576
  # FOOTER ------------------------------------------
 
1
  import streamlit as st
2
  import pandas as pd
3
  import numpy as np
 
 
 
4
  import matplotlib.pyplot as plt
5
+ import plotly.graph_objects as go
6
  from datetime import date
7
  from pathlib import Path
8
  import matplotlib.font_manager as fm
9
  import matplotlib as mpl
10
+ import warnings
11
+ warnings.filterwarnings('ignore')
12
+
13
+ # 필요한 추가 라이브러리 로드
14
+ try:
15
+ import statsmodels.api as sm
16
+ from statsmodels.tsa.statespace.sarimax import SARIMAX
17
+ from statsmodels.tsa.holtwinters import ExponentialSmoothing, SimpleExpSmoothing, Holt
18
+ from statsmodels.tsa.seasonal import seasonal_decompose
19
+ from sklearn.linear_model import LinearRegression
20
+ from sklearn.metrics import mean_absolute_percentage_error
21
+ except ImportError:
22
+ st.error("필요한 라이브러리가 설치되지 않았습니다. 터미널에서 다음 명령을 실행하세요:")
23
+ st.code("pip install statsmodels scikit-learn")
24
+ st.stop()
25
 
26
  # -------------------------------------------------
27
  # CONFIG ------------------------------------------
 
44
 
45
  st.set_page_config(page_title="품목별 가격 예측", page_icon="📈", layout="wide")
46
 
47
+ # -------------------------------------------------
48
+ # 품목별 최적 모델 매핑 ---------------------------
49
+ # -------------------------------------------------
50
+ item_models = {
51
+ "갈치": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 99.82, "model2": "Holt-Winters", "accuracy2": 99.80},
52
+ "감자": {"model1": "ETS(Multiplicative)", "accuracy1": 99.58, "model2": "SARIMA(1,0,1)(1,0,1,12)", "accuracy2": 98.70},
53
+ "건고추": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 99.96, "model2": "Holt", "accuracy2": 99.79},
54
+ "건다시마": {"model1": "Naive", "accuracy1": 99.59, "model2": "SeasonalNaive", "accuracy2": 99.34},
55
+ "고구마": {"model1": "SARIMA(1,1,1)(1,1,1,12)", "accuracy1": 99.89, "model2": "ETS(Multiplicative)", "accuracy2": 98.91},
56
+ "고등어": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 99.48, "model2": "ETS(Additive)", "accuracy2": 99.42},
57
+ "김": {"model1": "SARIMA(0,1,1)(0,1,1,12)", "accuracy1": 99.99, "model2": "SARIMA(0,1,1)(0,1,1,12)", "accuracy2": 99.93},
58
+ "깐마늘(국산)": {"model1": "SeasonalNaive", "accuracy1": 99.79, "model2": "MovingAverage-6 m", "accuracy2": 98.65},
59
+ "깻잎": {"model1": "SARIMA(0,1,1)(0,1,1,12)", "accuracy1": 99.68, "model2": "Holt", "accuracy2": 99.54},
60
+ "녹두": {"model1": "WeightedMA-6 m", "accuracy1": 99.53, "model2": "Fourier + LR", "accuracy2": 99.53},
61
+ "느타리버섯": {"model1": "SARIMA(0,1,1)(0,1,1,12)", "accuracy1": 99.84, "model2": "LinearTrend", "accuracy2": 99.80},
62
+ "당근": {"model1": "Holt", "accuracy1": 99.25, "model2": "ETS(Multiplicative)", "accuracy2": 97.27},
63
+ "들깨": {"model1": "Holt", "accuracy1": 99.57, "model2": "Holt-Winters", "accuracy2": 99.17},
64
+ "땅콩": {"model1": "SARIMA(1,1,1)(1,1,1,12)", "accuracy1": 99.74, "model2": "ETS(Additive)", "accuracy2": 99.37},
65
+ "레몬": {"model1": "WeightedMA-6 m", "accuracy1": 99.99, "model2": "LinearTrend", "accuracy2": 98.99},
66
+ "망고": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 99.38, "model2": "Holt-Winters", "accuracy2": 99.02},
67
+ "메밀": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 99.48, "model2": "SARIMA(0,1,1)(0,1,1,12)", "accuracy2": 98.99},
68
+ "멜론": {"model1": "Naive", "accuracy1": 99.07, "model2": "ETS(Multiplicative)", "accuracy2": 99.01},
69
+ "명태": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 100.00, "model2": "MovingAverage-6 m", "accuracy2": 99.93},
70
+ "무": {"model1": "SARIMA(1,1,1)(1,1,1,12)", "accuracy1": 99.54, "model2": "SeasonalNaive", "accuracy2": 88.29, "special": "accuracy_drop"},
71
+ "물오징어": {"model1": "Holt-Winters", "accuracy1": 99.91, "model2": "ETS(Multiplicative)", "accuracy2": 99.36},
72
+ "미나리": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 98.71, "model2": "LinearTrend", "accuracy2": 98.54},
73
+ "바나나": {"model1": "MovingAverage-6 m", "accuracy1": 99.81, "model2": "ETS(Multiplicative)", "accuracy2": 98.86},
74
+ "방울토마토": {"model1": "ETS(Multiplicative)", "accuracy1": 99.62, "model2": "Holt", "accuracy2": 98.28},
75
+ "배": {"model1": "ETS(Additive)", "accuracy1": 99.34, "model2": "LinearTrend", "accuracy2": 98.57},
76
+ "배추": {"model1": "Holt", "accuracy1": 99.98, "model2": "MovingAverage-6 m", "accuracy2": 99.71},
77
+ "북어": {"model1": "Fourier + LR", "accuracy1": 99.96, "model2": "MovingAverage-6 m", "accuracy2": 99.94},
78
+ "붉은고추": {"model1": "SARIMA(1,1,1)(1,1,1,12)", "accuracy1": 99.75, "model2": "LinearTrend", "accuracy2": 97.61},
79
+ "브로콜리": {"model1": "Holt", "accuracy1": 99.54, "model2": "Naive", "accuracy2": 99.93},
80
+ "사과": {"model1": "Holt-Winters", "accuracy1": 99.89, "model2": "ETS(Multiplicative)", "accuracy2": 98.91},
81
+ "상추": {"model1": "ETS(Additive)", "accuracy1": 99.11, "model2": "Holt-Winters", "accuracy2": 97.61},
82
+ "새송이버섯": {"model1": "SimpleExpSmoothing", "accuracy1": 99.95, "model2": "Holt-Winters", "accuracy2": 99.40},
83
+ "새우": {"model1": "ETS(Additive)", "accuracy1": 99.87, "model2": "Naive", "accuracy2": 99.96},
84
+ "생강": {"model1": "Naive", "accuracy1": 99.27, "model2": "ETS(Additive)", "accuracy2": 98.53},
85
+ "수박": {"model1": "Naive", "accuracy1": 99.91, "model2": "SARIMA(1,1,1)(1,1,1,12)", "accuracy2": 99.45},
86
+ "시금치": {"model1": "Holt-Winters", "accuracy1": 99.70, "model2": "SeasonalNaive", "accuracy2": 98.73},
87
+ "쌀": {"model1": "SARIMA(0,1,1)(0,1,1,12)", "accuracy1": 99.99, "model2": "Holt-Winters", "accuracy2": 99.88},
88
+ "알배기배추": {"model1": "WeightedMA-6 m", "accuracy1": 98.19, "model2": "SeasonalNaive", "accuracy2": 95.73},
89
+ "양배추": {"model1": "Holt-Winters", "accuracy1": 99.05, "model2": "WeightedMA-6 m", "accuracy2": 97.85},
90
+ "양파": {"model1": "ETS(Additive)", "accuracy1": 99.93, "model2": "WeightedMA-6 m", "accuracy2": 99.51},
91
+ "얼갈이배추": {"model1": "SARIMA(1,1,1)(1,1,1,12)", "accuracy1": 99.77, "model2": "SeasonalNaive", "accuracy2": 98.55},
92
+ "열무": {"model1": "SeasonalNaive", "accuracy1": 99.96, "model2": "Holt", "accuracy2": 99.50},
93
+ "오이": {"model1": "SeasonalNaive", "accuracy1": 99.82, "model2": "ETS(Additive)", "accuracy2": 98.48},
94
+ "전복": {"model1": "Holt", "accuracy1": 99.90, "model2": "Fourier + LR", "accuracy2": 99.90},
95
+ "참깨": {"model1": "WeightedMA-6 m", "accuracy1": 100.00, "model2": "LinearTrend", "accuracy2": 86.44, "special": "accuracy_drop"},
96
+ "찹쌀": {"model1": "SARIMA(1,0,1)(1,0,1,12)", "accuracy1": 99.71, "model2": "Naive", "accuracy2": 98.64, "special": "accuracy_drop"},
97
+ "콩": {"model1": "SARIMA(0,1,1)(0,1,1,12)", "accuracy1": 99.98, "model2": "ETS(Additive)", "accuracy2": 99.68},
98
+ "토마토": {"model1": "SeasonalNaive", "accuracy1": 97.31, "model2": "MovingAverage-6 m", "accuracy2": 97.57},
99
+ "파": {"model1": "MovingAverage-6 m", "accuracy1": 99.92, "model2": "Holt-Winters", "accuracy2": 97.77},
100
+ "파인애플": {"model1": "Naive", "accuracy1": 99.51, "model2": "SARIMA(1,0,1)(1,0,1,12)", "accuracy2": 96.39},
101
+ "파프리카": {"model1": "SARIMA(0,1,1)(0,1,1,12)", "accuracy1": 99.04, "model2": "WeightedMA-6 m", "accuracy2": 99.36},
102
+ "팥": {"model1": "ETS(Additive)", "accuracy1": 99.87, "model2": "Holt-Winters", "accuracy2": 75.08, "special": "accuracy_drop"},
103
+ "팽이버섯": {"model1": "SeasonalNaive", "accuracy1": 99.84, "model2": "Fourier + LR", "accuracy2": 98.49},
104
+ "풋고추": {"model1": "Holt-Winters", "accuracy1": 98.95, "model2": "ETS(Multiplicative)", "accuracy2": 98.73},
105
+ "피망": {"model1": "Fourier + LR", "accuracy1": 99.64, "model2": "WeightedMA-6 m", "accuracy2": 98.93},
106
+ "호박": {"model1": "ETS(Multiplicative)", "accuracy1": 99.98, "model2": "SeasonalNaive", "accuracy2": 96.61},
107
+ "홍합": {"model1": "Naive", "accuracy1": 99.86, "model2": "SeasonalNaive", "accuracy2": 98.56},
108
+ }
109
+
110
+ # 기타 품목에 대한 기본 모델 (리스트에 없는 품목)
111
+ default_models = {
112
+ "model1": "SARIMA(1,0,1)(1,0,1,12)",
113
+ "accuracy1": 99.0,
114
+ "model2": "ETS(Multiplicative)",
115
+ "accuracy2": 98.0
116
+ }
117
+
118
  # -------------------------------------------------
119
  # UTILITIES ---------------------------------------
120
  # -------------------------------------------------
 
122
  ITEM_CANDIDATES = {"item", "품목", "code", "category", "pdlt_nm", "spcs_nm"}
123
  PRICE_CANDIDATES = {"price", "y", "value", "가격", "avrg_prce"}
124
 
 
125
  def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
126
  """Standardize column names to date/item/price and deduplicate."""
127
  col_map = {}
 
170
 
171
  return df
172
 
 
173
  @st.cache_data(show_spinner=False)
174
  def load_data() -> pd.DataFrame:
175
  """Load price data from CSV file."""
 
224
  return df
225
  except Exception as e:
226
  st.error(f"데이터 로드 중 오류 발생: {str(e)}")
 
227
  import traceback
228
  st.code(traceback.format_exc())
229
  st.stop()
230
 
 
231
  @st.cache_data(show_spinner=False)
232
  def get_items(df: pd.DataFrame):
233
  return sorted(df["item"].unique())
234
 
235
+ def get_best_model_for_item(item):
236
+ """품목에 맞는 최적 모델 정보 반환"""
237
+ return item_models.get(item, default_models)
238
+
239
+ def format_currency(value):
240
+ """원화 형식으로 숫자 포맷팅"""
241
+ if pd.isna(value) or not np.isfinite(value):
242
+ return "N/A"
243
+ return f"{value:,.0f}원"
244
 
245
+ # -------------------------------------------------
246
+ # 모델 구현부 --------------------------------------
247
+ # -------------------------------------------------
248
  @st.cache_data(show_spinner=False, ttl=3600)
249
+ def prepare_monthly_data(df):
250
+ """월별 데이터 준비"""
251
+ # 월별로 집계
252
+ monthly_df = df.copy()
253
+ monthly_df['year_month'] = monthly_df['date'].dt.strftime('%Y-%m')
254
+ monthly_df = monthly_df.groupby('year_month').agg({'date': 'last', 'price': 'mean'}).reset_index(drop=True)
255
+ monthly_df.sort_values('date', inplace=True)
 
 
 
 
 
 
256
 
257
+ # 인덱스 설정
258
+ monthly_df.set_index('date', inplace=True)
 
259
 
260
+ # 결측치 보간 (월별 데이터에 빈 월이 있을 수 있음)
261
+ if len(monthly_df) > 1:
262
+ monthly_df = monthly_df.asfreq('M', method='ffill')
 
 
 
 
 
263
 
264
+ return monthly_df
265
+
266
+ def fit_sarima(df, order, seasonal_order, horizon_end):
267
+ """SARIMA 모델 구현"""
268
+ import pandas as pd
269
+ import numpy as np
270
+ from statsmodels.tsa.statespace.sarimax import SARIMAX
271
 
272
+ # 월별 데이터 준비
273
+ monthly_df = prepare_monthly_data(df)
274
 
275
+ # 모델 학습
276
  try:
277
+ model = SARIMAX(
278
+ monthly_df['price'],
279
+ order=order,
280
+ seasonal_order=seasonal_order,
281
+ enforce_stationarity=False,
282
+ enforce_invertibility=False
 
 
283
  )
284
+ results = model.fit(disp=False)
285
 
286
+ # 예측 기간 계산
287
+ last_date = monthly_df.index[-1]
288
+ end_date = pd.Timestamp(horizon_end)
289
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
290
 
291
+ # 예측 수행
292
+ forecast = results.get_forecast(steps=periods)
293
+ pred_mean = forecast.predicted_mean
294
+ pred_ci = forecast.conf_int()
295
 
296
+ # Prophet 형식으로 결과 변환
297
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
298
+
299
+ fc_df = pd.DataFrame({
300
+ 'ds': future_dates,
301
+ 'yhat': pred_mean.values,
302
+ 'yhat_lower': pred_ci.iloc[:, 0].values,
303
+ 'yhat_upper': pred_ci.iloc[:, 1].values
304
+ })
 
 
305
 
306
+ # 월별로 결과 변환 (날짜, 가격)
307
+ fc_df_monthly = pd.DataFrame({
308
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
309
+ })
310
 
311
+ # 학습 데이터 기간의 ��과 추가
312
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
313
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
314
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
315
 
316
+ # 예측 기간의 결과 추가
317
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
318
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
319
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
320
+
321
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
322
+ fc_df_monthly['yearly'] = 0
323
+ fc_df_monthly['trend'] = 0
324
+
325
+ try:
326
+ # 가능하면 계절성 분해
327
+ decomposition = seasonal_decompose(monthly_df['price'], model='multiplicative', period=12)
328
+ trend = decomposition.trend
329
+ seasonal = decomposition.seasonal
330
+
331
+ # 결과에 계절성 반영
332
+ for i, date in enumerate(fc_df_monthly['ds']):
333
+ month = date.month
334
+ if month in seasonal.index.month:
335
+ seasonal_value = seasonal[seasonal.index.month == month].mean()
336
+ fc_df_monthly.loc[i, 'yearly'] = seasonal_value
337
+ except:
338
+ pass
339
+
340
+ return fc_df_monthly
341
+
342
  except Exception as e:
343
+ st.error(f"SARIMA 모델 오류: {str(e)}")
344
+ return None
345
 
346
+ def fit_ets(df, seasonal_type, horizon_end):
347
+ """ETS 모델 구현"""
348
+ # 월별 데이터 준비
349
+ monthly_df = prepare_monthly_data(df)
350
+
351
+ # 모델 파라미터 설정
352
+ if seasonal_type == 'multiplicative':
353
+ trend_type = 'add'
354
+ seasonal = 'mul'
355
+ else: # additive
356
+ trend_type = 'add'
357
+ seasonal = 'add'
358
+
359
+ # 모델 학습
360
+ try:
361
+ model = ExponentialSmoothing(
362
+ monthly_df['price'],
363
+ trend=trend_type,
364
+ seasonal=seasonal,
365
+ seasonal_periods=12,
366
+ damped=True
367
+ )
368
+ results = model.fit(optimized=True)
369
+
370
+ # 예측 기간 계산
371
+ last_date = monthly_df.index[-1]
372
+ end_date = pd.Timestamp(horizon_end)
373
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
374
+
375
+ # 예측 수행
376
+ forecast = results.forecast(periods)
377
+
378
+ # Prophet 형식으로 결과 변환
379
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
380
+
381
+ # 신뢰 구간 추정 (ETS는 기본 신뢰 구간을 제공하지 않음)
382
+ std_error = np.std(results.resid)
383
+ lower_bound = forecast - 1.96 * std_error
384
+ upper_bound = forecast + 1.96 * std_error
385
+
386
+ fc_df = pd.DataFrame({
387
+ 'ds': future_dates,
388
+ 'yhat': forecast.values,
389
+ 'yhat_lower': lower_bound.values,
390
+ 'yhat_upper': upper_bound.values
391
+ })
392
+
393
+ # 월별로 결과 변환
394
+ fc_df_monthly = pd.DataFrame({
395
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
396
+ })
397
+
398
+ # 학습 데이터 기간의 결과 추가
399
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
400
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
401
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
402
+
403
+ # 예측 기간의 결과 추가
404
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
405
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
406
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
407
+
408
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
409
+ fc_df_monthly['yearly'] = 0
410
+ fc_df_monthly['trend'] = 0
411
+
412
+ try:
413
+ # 가능하면 계절성 분해
414
+ decomposition = seasonal_decompose(monthly_df['price'], model=seasonal_type, period=12)
415
+ trend = decomposition.trend
416
+ seasonal = decomposition.seasonal
417
+
418
+ # 결과에 계절성 반영
419
+ for i, date in enumerate(fc_df_monthly['ds']):
420
+ month = date.month
421
+ if month in seasonal.index.month:
422
+ seasonal_value = seasonal[seasonal.index.month == month].mean()
423
+ fc_df_monthly.loc[i, 'yearly'] = seasonal_value
424
+ except:
425
+ pass
426
+
427
+ return fc_df_monthly
428
+
429
+ except Exception as e:
430
+ st.error(f"ETS 모델 오류: {str(e)}")
431
+ return None
432
 
433
+ def fit_holt(df, horizon_end):
434
+ """Holt 모델 구현"""
435
+ # 월별 데이터 준비
436
+ monthly_df = prepare_monthly_data(df)
437
+
438
+ # 모델 학습
439
+ try:
440
+ model = Holt(monthly_df['price'], damped=True)
441
+ results = model.fit(optimized=True)
442
+
443
+ # 예측 기간 계산
444
+ last_date = monthly_df.index[-1]
445
+ end_date = pd.Timestamp(horizon_end)
446
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
447
+
448
+ # 예측 수행
449
+ forecast = results.forecast(periods)
450
+
451
+ # Prophet 형식으로 결과 변환
452
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
453
+
454
+ # 신뢰 구간 추정
455
+ std_error = np.std(results.resid)
456
+ lower_bound = forecast - 1.96 * std_error
457
+ upper_bound = forecast + 1.96 * std_error
458
+
459
+ fc_df = pd.DataFrame({
460
+ 'ds': future_dates,
461
+ 'yhat': forecast.values,
462
+ 'yhat_lower': lower_bound.values,
463
+ 'yhat_upper': upper_bound.values
464
+ })
465
+
466
+ # 월별로 결과 변환
467
+ fc_df_monthly = pd.DataFrame({
468
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
469
+ })
470
+
471
+ # 학습 데이터 기간의 결과 추가
472
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
473
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
474
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
475
+
476
+ # 예측 기간의 결과 추가
477
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
478
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
479
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
480
+
481
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
482
+ fc_df_monthly['yearly'] = 0
483
+ fc_df_monthly['trend'] = fc_df_monthly['yhat'] # Holt는 추세만 모델링
484
+
485
+ return fc_df_monthly
486
+
487
+ except Exception as e:
488
+ st.error(f"Holt 모델 오류: {str(e)}")
489
+ return None
490
+
491
+ def fit_holt_winters(df, horizon_end):
492
+ """Holt-Winters 모델 구현"""
493
+ # 월별 데이터 준비
494
+ monthly_df = prepare_monthly_data(df)
495
+
496
+ # 모델 학습
497
+ try:
498
+ model = ExponentialSmoothing(
499
+ monthly_df['price'],
500
+ trend='add',
501
+ seasonal='mul', # 계절성은 곱셈 방식이 농산물 가격에 더 적합
502
+ seasonal_periods=12,
503
+ damped=True
504
+ )
505
+ results = model.fit(optimized=True)
506
+
507
+ # 예측 기간 계산
508
+ last_date = monthly_df.index[-1]
509
+ end_date = pd.Timestamp(horizon_end)
510
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
511
+
512
+ # 예측 수행
513
+ forecast = results.forecast(periods)
514
+
515
+ # Prophet 형식으로 결과 변환
516
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
517
+
518
+ # 신뢰 구간 추정
519
+ std_error = np.std(results.resid)
520
+ lower_bound = forecast - 1.96 * std_error
521
+ upper_bound = forecast + 1.96 * std_error
522
+
523
+ fc_df = pd.DataFrame({
524
+ 'ds': future_dates,
525
+ 'yhat': forecast.values,
526
+ 'yhat_lower': lower_bound.values,
527
+ 'yhat_upper': upper_bound.values
528
+ })
529
+
530
+ # 월별로 결과 변환
531
+ fc_df_monthly = pd.DataFrame({
532
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
533
+ })
534
+
535
+ # 학습 데이터 기간의 결과 추가
536
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
537
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
538
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
539
+
540
+ # 예측 기간의 결과 추가
541
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
542
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
543
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
544
+
545
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
546
+ fc_df_monthly['yearly'] = 0
547
+ fc_df_monthly['trend'] = 0
548
+
549
+ try:
550
+ # Holt-Winters 모델에서 계절성 추출
551
+ seasonal = results.seasonal_
552
+
553
+ # 결과에 계절성 반영
554
+ for i, date in enumerate(fc_df_monthly['ds']):
555
+ month = date.month - 1 # 0-indexed
556
+ if month < len(seasonal):
557
+ fc_df_monthly.loc[i, 'yearly'] = seasonal[month] * fc_df_monthly.loc[i, 'yhat']
558
+ fc_df_monthly.loc[i, 'trend'] = fc_df_monthly.loc[i, 'yhat'] - fc_df_monthly.loc[i, 'yearly']
559
+ except:
560
+ pass
561
+
562
+ return fc_df_monthly
563
+
564
+ except Exception as e:
565
+ st.error(f"Holt-Winters 모델 오류: {str(e)}")
566
+ return None
567
+
568
+ def fit_moving_average(df, window, horizon_end):
569
+ """이동 평균 모델 구현"""
570
+ # 월별 데이터 준비
571
+ monthly_df = prepare_monthly_data(df)
572
+
573
+ try:
574
+ # 마지막 window 개월의 평균 계산
575
+ last_values = monthly_df['price'].iloc[-window:]
576
+ ma_value = last_values.mean()
577
+
578
+ # 예측 기간 계산
579
+ last_date = monthly_df.index[-1]
580
+ end_date = pd.Timestamp(horizon_end)
581
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
582
+
583
+ # 예측 수행 (모든 미래 시점에 동일한 값)
584
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
585
+
586
+ # 신뢰 구간 추정
587
+ std_error = last_values.std()
588
+ lower_bound = ma_value - 1.96 * std_error
589
+ upper_bound = ma_value + 1.96 * std_error
590
+
591
+ fc_df = pd.DataFrame({
592
+ 'ds': future_dates,
593
+ 'yhat': [ma_value] * len(future_dates),
594
+ 'yhat_lower': [lower_bound] * len(future_dates),
595
+ 'yhat_upper': [upper_bound] * len(future_dates)
596
+ })
597
+
598
+ # 월별로 결과 변환
599
+ fc_df_monthly = pd.DataFrame({
600
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
601
+ })
602
+
603
+ # 학습 데이터 기간의 결과 추가
604
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
605
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
606
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
607
+
608
+ # 예측 기간의 결과 추가
609
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
610
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
611
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
612
+
613
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
614
+ fc_df_monthly['yearly'] = 0
615
+ fc_df_monthly['trend'] = fc_df_monthly['yhat']
616
+
617
+ return fc_df_monthly
618
+
619
+ except Exception as e:
620
+ st.error(f"이동 평균 모델 오류: {str(e)}")
621
+ return None
622
+
623
+ def fit_weighted_ma(df, window, horizon_end):
624
+ """가중 이동 평균 모델 구현"""
625
+ # 월별 데이터 준비
626
+ monthly_df = prepare_monthly_data(df)
627
+
628
+ try:
629
+ # 마지막 window 개월의 가중 평균 계산
630
+ last_values = monthly_df['price'].iloc[-window:].to_numpy()
631
+
632
+ # 가중치 생성 (최근 데이터에 더 높은 가중치)
633
+ weights = np.arange(1, window + 1)
634
+ weights = weights / np.sum(weights)
635
+
636
+ wma_value = np.sum(last_values * weights)
637
+
638
+ # 예측 기간 계산
639
+ last_date = monthly_df.index[-1]
640
+ end_date = pd.Timestamp(horizon_end)
641
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
642
+
643
+ # 예측 수행 (모든 미래 시점에 동일한 값)
644
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
645
+
646
+ # 신뢰 구간 추정
647
+ std_error = np.std(last_values)
648
+ lower_bound = wma_value - 1.96 * std_error
649
+ upper_bound = wma_value + 1.96 * std_error
650
+
651
+ fc_df = pd.DataFrame({
652
+ 'ds': future_dates,
653
+ 'yhat': [wma_value] * len(future_dates),
654
+ 'yhat_lower': [lower_bound] * len(future_dates),
655
+ 'yhat_upper': [upper_bound] * len(future_dates)
656
+ })
657
+
658
+ # 월별로 결과 변환
659
+ fc_df_monthly = pd.DataFrame({
660
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
661
+ })
662
+
663
+ # 학습 데이터 기간의 결과 추가
664
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
665
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
666
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
667
+
668
+ # 예측 기간의 결과 추가
669
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
670
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
671
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
672
+
673
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
674
+ fc_df_monthly['yearly'] = 0
675
+ fc_df_monthly['trend'] = fc_df_monthly['yhat']
676
+
677
+ return fc_df_monthly
678
+
679
+ except Exception as e:
680
+ st.error(f"가중 이동 평균 모델 오류: {str(e)}")
681
+ return None
682
+
683
+ def fit_naive(df, horizon_end):
684
+ """단순 Naive 모델 구현"""
685
+ # 월별 데이터 준비
686
+ monthly_df = prepare_monthly_data(df)
687
+
688
+ try:
689
+ # 마지막 값 사용
690
+ last_value = monthly_df['price'].iloc[-1]
691
+
692
+ # 예측 기간 계산
693
+ last_date = monthly_df.index[-1]
694
+ end_date = pd.Timestamp(horizon_end)
695
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
696
+
697
+ # 예측 수행 (모든 미래 시점에 마지막 값)
698
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
699
+
700
+ # 신뢰 구간 추정 (과거 12개월 표준편차 사용)
701
+ history_std = monthly_df['price'].iloc[-12:].std() if len(monthly_df) >= 12 else monthly_df['price'].std()
702
+ lower_bound = last_value - 1.96 * history_std
703
+ upper_bound = last_value + 1.96 * history_std
704
+
705
+ fc_df = pd.DataFrame({
706
+ 'ds': future_dates,
707
+ 'yhat': [last_value] * len(future_dates),
708
+ 'yhat_lower': [lower_bound] * len(future_dates),
709
+ 'yhat_upper': [upper_bound] * len(future_dates)
710
+ })
711
+
712
+ # 월별로 결과 변환
713
+ fc_df_monthly = pd.DataFrame({
714
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
715
+ })
716
+
717
+ # 학습 데이터 기간의 결과 추가
718
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
719
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
720
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
721
+
722
+ # 예측 기간의 결과 추가
723
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
724
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
725
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
726
+
727
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
728
+ fc_df_monthly['yearly'] = 0
729
+ fc_df_monthly['trend'] = fc_df_monthly['yhat']
730
+
731
+ return fc_df_monthly
732
+
733
+ except Exception as e:
734
+ st.error(f"Naive 모델 오류: {str(e)}")
735
+ return None
736
+
737
+ def fit_seasonal_naive(df, horizon_end):
738
+ """계절성 Naive 모델 구현"""
739
+ # 월별 데이터 준비
740
+ monthly_df = prepare_monthly_data(df)
741
+
742
+ try:
743
+ # 예측 기간 계산
744
+ last_date = monthly_df.index[-1]
745
+ end_date = pd.Timestamp(horizon_end)
746
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
747
+
748
+ # 예측 수행 (각 월에 대해 작년 같은 달 가격 사용)
749
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
750
+ future_values = []
751
+ lower_bounds = []
752
+ upper_bounds = []
753
+
754
+ for date in future_dates:
755
+ # 같은 월의 값 찾기
756
+ same_month_values = monthly_df[monthly_df.index.month == date.month]['price']
757
+
758
+ if len(same_month_values) > 0:
759
+ # 같은 월 가장 최근 값 사용
760
+ forecast_value = same_month_values.iloc[-1]
761
+
762
+ # 신뢰 구간
763
+ std_error = same_month_values.std() if len(same_month_values) > 1 else monthly_df['price'].std()
764
+ lower_bound = forecast_value - 1.96 * std_error
765
+ upper_bound = forecast_value + 1.96 * std_error
766
+ else:
767
+ # 같은 월 데이터 없으면 전체 평균 사용
768
+ forecast_value = monthly_df['price'].mean()
769
+ std_error = monthly_df['price'].std()
770
+ lower_bound = forecast_value - 1.96 * std_error
771
+ upper_bound = forecast_value + 1.96 * std_error
772
+
773
+ future_values.append(forecast_value)
774
+ lower_bounds.append(lower_bound)
775
+ upper_bounds.append(upper_bound)
776
+
777
+ fc_df = pd.DataFrame({
778
+ 'ds': future_dates,
779
+ 'yhat': future_values,
780
+ 'yhat_lower': lower_bounds,
781
+ 'yhat_upper': upper_bounds
782
+ })
783
+
784
+ # 월별로 결과 변환
785
+ fc_df_monthly = pd.DataFrame({
786
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
787
+ })
788
+
789
+ # 학습 데이터 기간의 결과 추가
790
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
791
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
792
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
793
+
794
+ # 예측 기간의 결과 추가
795
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
796
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
797
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
798
+
799
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
800
+ fc_df_monthly['yearly'] = fc_df_monthly['yhat']
801
+ fc_df_monthly['trend'] = 0
802
+
803
+ return fc_df_monthly
804
+
805
+ except Exception as e:
806
+ st.error(f"Seasonal Naive 모델 오류: {str(e)}")
807
+ return None
808
+
809
+ def fit_fourier_lr(df, horizon_end):
810
+ """Fourier + 선형 회귀 모델 구현"""
811
+ from sklearn.linear_model import LinearRegression
812
+
813
+ # 월별 데이터 준비
814
+ monthly_df = prepare_monthly_data(df)
815
+
816
+ try:
817
+ # 시간 변수 생성
818
+ y = monthly_df['price'].values
819
+ t = np.arange(len(y))
820
+
821
+ # Fourier 특성 생성 (연간 계절성)
822
+ p = 12 # 주기 (1년)
823
+ X = np.column_stack([
824
+ t, # 선형 추세
825
+ np.sin(2 * np.pi * t / p),
826
+ np.cos(2 * np.pi * t / p),
827
+ np.sin(4 * np.pi * t / p),
828
+ np.cos(4 * np.pi * t / p)
829
+ ])
830
+
831
+ # 모델 학습
832
+ model = LinearRegression()
833
+ model.fit(X, y)
834
+
835
+ # 예측 기간 계산
836
+ last_date = monthly_df.index[-1]
837
+ end_date = pd.Timestamp(horizon_end)
838
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
839
+
840
+ # 예측 수행
841
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
842
+
843
+ # 미래 시점 특성 생성
844
+ t_future = np.arange(len(y), len(y) + periods)
845
+ X_future = np.column_stack([
846
+ t_future,
847
+ np.sin(2 * np.pi * t_future / p),
848
+ np.cos(2 * np.pi * t_future / p),
849
+ np.sin(4 * np.pi * t_future / p),
850
+ np.cos(4 * np.pi * t_future / p)
851
+ ])
852
+
853
+ # 예측
854
+ forecast = model.predict(X_future)
855
+
856
+ # 신뢰 구간 추정
857
+ y_pred = model.predict(X)
858
+ mse = np.mean((y - y_pred) ** 2)
859
+ std_error = np.sqrt(mse)
860
+
861
+ lower_bound = forecast - 1.96 * std_error
862
+ upper_bound = forecast + 1.96 * std_error
863
+
864
+ fc_df = pd.DataFrame({
865
+ 'ds': future_dates,
866
+ 'yhat': forecast,
867
+ 'yhat_lower': lower_bound,
868
+ 'yhat_upper': upper_bound
869
+ })
870
+
871
+ # 월별로 결과 변환
872
+ fc_df_monthly = pd.DataFrame({
873
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
874
+ })
875
+
876
+ # 학습 데이터 기간의 결과 추가
877
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
878
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
879
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
880
+
881
+ # 예측 기간의 결과 추가
882
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
883
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
884
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
885
+
886
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
887
+ fc_df_monthly['trend'] = model.coef_[0] * np.arange(len(fc_df_monthly)) + model.intercept_
888
+
889
+ # 계절성 계산
890
+ season_features = np.column_stack([
891
+ np.sin(2 * np.pi * np.arange(len(fc_df_monthly)) / p),
892
+ np.cos(2 * np.pi * np.arange(len(fc_df_monthly)) / p),
893
+ np.sin(4 * np.pi * np.arange(len(fc_df_monthly)) / p),
894
+ np.cos(4 * np.pi * np.arange(len(fc_df_monthly)) / p)
895
+ ])
896
+
897
+ seasonal_effect = np.dot(season_features, model.coef_[1:5])
898
+ fc_df_monthly['yearly'] = seasonal_effect
899
+
900
+ return fc_df_monthly
901
+
902
+ except Exception as e:
903
+ st.error(f"Fourier + LR 모델 오류: {str(e)}")
904
+ return None
905
+
906
+ def fit_linear_trend(df, horizon_end):
907
+ """선형 추세 모델 구현"""
908
+ from sklearn.linear_model import LinearRegression
909
+
910
+ # 월별 데이터 준비
911
+ monthly_df = prepare_monthly_data(df)
912
+
913
+ try:
914
+ # 시간 변수 생성
915
+ y = monthly_df['price'].values
916
+ t = np.arange(len(y)).reshape(-1, 1)
917
+
918
+ # 모델 학습
919
+ model = LinearRegression()
920
+ model.fit(t, y)
921
+
922
+ # 예측 기간 계산
923
+ last_date = monthly_df.index[-1]
924
+ end_date = pd.Timestamp(horizon_end)
925
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
926
+
927
+ # 예측 수행
928
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
929
+ t_future = np.arange(len(y), len(y) + periods).reshape(-1, 1)
930
+ forecast = model.predict(t_future)
931
+
932
+ # 신뢰 구간 추정
933
+ y_pred = model.predict(t)
934
+ mse = np.mean((y - y_pred) ** 2)
935
+ std_error = np.sqrt(mse)
936
+
937
+ lower_bound = forecast - 1.96 * std_error
938
+ upper_bound = forecast + 1.96 * std_error
939
+
940
+ fc_df = pd.DataFrame({
941
+ 'ds': future_dates,
942
+ 'yhat': forecast,
943
+ 'yhat_lower': lower_bound,
944
+ 'yhat_upper': upper_bound
945
+ })
946
+
947
+ # 월별로 결과 변환
948
+ fc_df_monthly = pd.DataFrame({
949
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
950
+ })
951
+
952
+ # 학습 데이터 기간의 결과 추가
953
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
954
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
955
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
956
+
957
+ # 예측 기간의 결과 추가
958
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
959
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
960
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
961
+
962
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
963
+ fc_df_monthly['yearly'] = 0
964
+ fc_df_monthly['trend'] = fc_df_monthly['yhat']
965
+
966
+ return fc_df_monthly
967
+
968
+ except Exception as e:
969
+ st.error(f"Linear Trend 모델 오류: {str(e)}")
970
+ return None
971
 
972
+ def fit_simple_exp_smoothing(df, horizon_end):
973
+ """단순 지수 평활 모델 구현"""
974
+ # 월별 데이터 준비
975
+ monthly_df = prepare_monthly_data(df)
976
+
977
+ try:
978
+ # 모델 학습
979
+ model = SimpleExpSmoothing(monthly_df['price'])
980
+ results = model.fit(optimized=True)
981
+
982
+ # 예측 기간 계산
983
+ last_date = monthly_df.index[-1]
984
+ end_date = pd.Timestamp(horizon_end)
985
+ periods = (end_date.year - last_date.year) * 12 + (end_date.month - last_date.month)
986
+
987
+ # 예측 수행
988
+ forecast = results.forecast(periods)
989
+
990
+ # 신뢰 구간 추정
991
+ std_error = np.std(results.resid)
992
+ lower_bound = forecast - 1.96 * std_error
993
+ upper_bound = forecast + 1.96 * std_error
994
+
995
+ # Prophet 형식으로 결과 변환
996
+ future_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=periods, freq='M')
997
+
998
+ fc_df = pd.DataFrame({
999
+ 'ds': future_dates,
1000
+ 'yhat': forecast.values,
1001
+ 'yhat_lower': lower_bound.values,
1002
+ 'yhat_upper': upper_bound.values
1003
+ })
1004
+
1005
+ # 월별로 결과 변환
1006
+ fc_df_monthly = pd.DataFrame({
1007
+ 'ds': pd.date_range(start=monthly_df.index[0], end=future_dates[-1], freq='M'),
1008
+ })
1009
+
1010
+ # 학습 데이터 기간의 결과 추가
1011
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat'] = monthly_df['price'].values
1012
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_lower'] = monthly_df['price'].values
1013
+ fc_df_monthly.loc[:len(monthly_df)-1, 'yhat_upper'] = monthly_df['price'].values
1014
+
1015
+ # 예측 기간의 결과 추가
1016
+ fc_df_monthly.loc[len(monthly_df):, 'yhat'] = fc_df['yhat'].values
1017
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_lower'] = fc_df['yhat_lower'].values
1018
+ fc_df_monthly.loc[len(monthly_df):, 'yhat_upper'] = fc_df['yhat_upper'].values
1019
+
1020
+ # yearly, trend 컴포넌트 추가 (Prophet 호환)
1021
+ fc_df_monthly['yearly'] = 0
1022
+ fc_df_monthly['trend'] = fc_df_monthly['yhat']
1023
+
1024
+ return fc_df_monthly
1025
+
1026
+ except Exception as e:
1027
+ st.error(f"Simple Exponential Smoothing 모델 오류: {str(e)}")
1028
+ return None
1029
+
1030
+ @st.cache_data(show_spinner=False, ttl=3600)
1031
+ def fit_optimal_model(df, item_name, horizon_end, model_type="primary"):
1032
+ """품목별 최적 모델 적용"""
1033
+ # 데이터 준비 및 정리
1034
+ df = df.copy()
1035
+ df = df.dropna(subset=["date", "price"])
1036
+
1037
+ # 품목별 최적 모델 선택
1038
+ model_info = get_best_model_for_item(item_name)
1039
+
1040
+ if model_type == "primary":
1041
+ model_name = model_info["model1"]
1042
+ accuracy = model_info["accuracy1"]
1043
+ else: # backup
1044
+ model_name = model_info["model2"]
1045
+ accuracy = model_info["accuracy2"]
1046
+
1047
+ st.info(f"{item_name}에 최적화된 {model_name} 모델 적용 (정확도: {accuracy}%)")
1048
+
1049
+ # 특수 처리가 필요한 품목 확인
1050
+ needs_monitoring = "special" in model_info and model_info["special"] == "accuracy_drop"
1051
+ if needs_monitoring:
1052
+ st.warning(f"⚠️ {item_name}는 특정 월에 정확도가 급락할 수 있는 품목입니다. 예측 결과를 주의 깊게 살펴보세요.")
1053
+
1054
+ # 모델 선택 및 학습
1055
+ if "SARIMA(1,0,1)(1,0,1,12)" in model_name:
1056
+ return fit_sarima(df, order=(1,0,1), seasonal_order=(1,0,1,12), horizon_end=horizon_end)
1057
+ elif "SARIMA(1,1,1)(1,1,1,12)" in model_name:
1058
+ return fit_sarima(df, order=(1,1,1), seasonal_order=(1,1,1,12), horizon_end=horizon_end)
1059
+ elif "SARIMA(0,1,1)(0,1,1,12)" in model_name:
1060
+ return fit_sarima(df, order=(0,1,1), seasonal_order=(0,1,1,12), horizon_end=horizon_end)
1061
+ elif "ETS(Multiplicative)" in model_name:
1062
+ return fit_ets(df, seasonal_type="multiplicative", horizon_end=horizon_end)
1063
+ elif "ETS(Additive)" in model_name:
1064
+ return fit_ets(df, seasonal_type="additive", horizon_end=horizon_end)
1065
+ elif "Holt-Winters" in model_name:
1066
+ return fit_holt_winters(df, horizon_end=horizon_end)
1067
+ elif "Holt" in model_name:
1068
+ return fit_holt(df, horizon_end=horizon_end)
1069
+ elif "MovingAverage-6 m" in model_name:
1070
+ return fit_moving_average(df, window=6, horizon_end=horizon_end)
1071
+ elif "WeightedMA-6 m" in model_name:
1072
+ return fit_weighted_ma(df, window=6, horizon_end=horizon_end)
1073
+ elif "Naive" in model_name and "Seasonal" not in model_name:
1074
+ return fit_naive(df, horizon_end=horizon_end)
1075
+ elif "SeasonalNaive" in model_name:
1076
+ return fit_seasonal_naive(df, horizon_end=horizon_end)
1077
+ elif "Fourier + LR" in model_name:
1078
+ return fit_fourier_lr(df, horizon_end=horizon_end)
1079
+ elif "LinearTrend" in model_name:
1080
+ return fit_linear_trend(df, horizon_end=horizon_end)
1081
+ elif "SimpleExpSmoothing" in model_name:
1082
+ return fit_simple_exp_smoothing(df, horizon_end=horizon_end)
1083
+ else:
1084
+ st.warning(f"알 수 없는 모델: {model_name}. 기본 모델(SARIMA)을 사용합니다.")
1085
+ return fit_sarima(df, order=(1,0,1), seasonal_order=(1,0,1,12), horizon_end=horizon_end)
1086
+
1087
+ def fit_ensemble_model(df, item_name, horizon_end):
1088
+ """1위와 2위 모델의 앙상블 수행"""
1089
+ # 1위 모델 예측
1090
+ fc1 = fit_optimal_model(df, item_name, horizon_end, model_type="primary")
1091
+
1092
+ # 2위 모델 예측
1093
+ fc2 = fit_optimal_model(df, item_name, horizon_end, model_type="backup")
1094
+
1095
+ # 두 모델 모두 성공한 경우만 앙상블
1096
+ if fc1 is not None and fc2 is not None:
1097
+ # 앙상블 가중치 계산 (정확도 기반)
1098
+ model_info = get_best_model_for_item(item_name)
1099
+ acc1 = model_info["accuracy1"]
1100
+ acc2 = model_info["accuracy2"]
1101
+
1102
+ # 정확도 차이가 0.2%p 이내인 경우 앙상블 수행
1103
+ accuracy_diff = abs(acc1 - acc2)
1104
+
1105
+ if accuracy_diff <= 0.2:
1106
+ st.success(f"두 모델의 정확도 차이가 {accuracy_diff:.2f}%p로 작아 앙상블을 수행합니다.")
1107
+
1108
+ # 정확도 기반 가중치 계산
1109
+ total_acc = acc1 + acc2
1110
+ w1 = acc1 / total_acc
1111
+ w2 = acc2 / total_acc
1112
+
1113
+ # 앙상블 결과 생성
1114
+ fc_ensemble = fc1.copy()
1115
+ fc_ensemble['yhat'] = w1 * fc1['yhat'] + w2 * fc2['yhat']
1116
+ fc_ensemble['yhat_lower'] = w1 * fc1['yhat_lower'] + w2 * fc2['yhat_lower']
1117
+ fc_ensemble['yhat_upper'] = w1 * fc1['yhat_upper'] + w2 * fc2['yhat_upper']
1118
+
1119
+ return fc_ensemble
1120
+ else:
1121
+ st.info(f"정확도 차이가 {accuracy_diff:.2f}%p로 커서 1위 모델만 사용합니다.")
1122
+ return fc1
1123
+
1124
+ # 하나라도 실패한 경우 성공한 모델 반환
1125
+ return fc1 if fc1 is not None else fc2
1126
 
1127
  # -------------------------------------------------
1128
+ # MAIN APP ---------------------------------------
1129
  # -------------------------------------------------
1130
+ # 데이터 로드
1131
  raw_df = load_data()
1132
 
1133
  if len(raw_df) == 0:
 
1139
  current_date = date.today()
1140
  st.sidebar.caption(f"오늘: {current_date}")
1141
 
1142
+ # 선택된 품목의 최적 모델 정보 표시
1143
+ model_info = get_best_model_for_item(selected_item)
1144
+ st.sidebar.subheader("품목별 최적 모델")
1145
+ st.sidebar.markdown(f"**1위 모델:** {model_info['model1']} (정확도: {model_info['accuracy1']}%)")
1146
+ st.sidebar.markdown(f"**2위 모델:** {model_info['model2']} (정확도: {model_info['accuracy2']}%)")
1147
+
1148
+ # 데이터 필터링
1149
  item_df = raw_df.query("item == @selected_item").copy()
1150
  if item_df.empty:
1151
  st.error("선택한 품목 데이터 없음")
 
1180
 
1181
  if len(macro_df) < 2:
1182
  st.warning(f"{selected_item}에 대한 데이터가 충분하지 않습니다. 전체 기간 데이터를 표시합니다.")
1183
+ fig = go.Figure()
1184
+ fig.add_trace(go.Scatter(x=item_df["date"], y=item_df["price"], mode="lines", name="실제 가격"))
1185
+ fig.update_layout(title=f"{selected_item} 과거 가격")
1186
  st.plotly_chart(fig, use_container_width=True)
1187
  else:
1188
  try:
1189
+ # 데이터 충분한 경우 품목별 최적 모델 사용
1190
+ use_ensemble = st.checkbox("앙상블 모델 사용 (1위 + 2위 모델 결합)", value=False)
1191
+
1192
  with st.spinner("장기 예측 모델 생성 중..."):
1193
+ if use_ensemble:
1194
+ fc_macro = fit_ensemble_model(macro_df, selected_item, MACRO_END)
1195
+ else:
1196
+ fc_macro = fit_optimal_model(macro_df, selected_item, MACRO_END)
1197
 
1198
+ if fc_macro is not None:
1199
  # 실제 데이터와 예측 데이터 구분
1200
  cutoff_date = pd.Timestamp("2025-01-01")
1201
 
 
1213
  line=dict(color="blue", width=2)
1214
  ))
1215
 
1216
+ # 예측 기간 자르기
1217
  forecast_data = fc_macro[fc_macro["ds"] >= cutoff_date].copy()
1218
+
1219
+ # 2025-2030 예측 데이터
1220
  if not forecast_data.empty:
1221
  fig.add_trace(go.Scatter(
1222
  x=forecast_data["ds"],
 
1244
  name="95% 신뢰 구간"
1245
  ))
1246
 
1247
+ # 음수 예측값 제거
1248
+ fig.update_yaxes(range=[0, None])
1249
+
1250
  # 레이아웃 설정
1251
  fig.update_layout(
1252
  title=f"{selected_item} 장기 가격 예측 (1996-2030)",
 
1282
  st.error(f"예측가 계산 오류: {str(e)}")
1283
  else:
1284
  st.warning("예측 모델을 생성할 수 없습니다.")
1285
+ fig = go.Figure()
1286
+ fig.add_trace(go.Scatter(x=macro_df["date"], y=macro_df["price"], mode="lines", name="실제 가격"))
1287
+ fig.update_layout(title=f"{selected_item} 과거 가격")
1288
  st.plotly_chart(fig, use_container_width=True)
1289
  except Exception as e:
1290
  st.error(f"장기 예측 오류 발생: {str(e)}")
1291
+ import traceback
1292
+ st.code(traceback.format_exc())
1293
+ fig = go.Figure()
1294
+ fig.add_trace(go.Scatter(x=macro_df["date"], y=macro_df["price"], mode="lines", name="실제 가격"))
1295
+ fig.update_layout(title=f"{selected_item} 과거 가격")
1296
  st.plotly_chart(fig, use_container_width=True)
1297
 
1298
  # -------------------------------------------------
 
1314
 
1315
  if len(micro_df) < 2:
1316
  st.warning(f"최근 데이터가 충분하지 않습니다.")
1317
+ fig = go.Figure()
1318
+ fig.add_trace(go.Scatter(x=item_df["date"], y=item_df["price"], mode="lines", name="실제 가격"))
1319
+ fig.update_layout(title=f"{selected_item} 최근 가격")
1320
  st.plotly_chart(fig, use_container_width=True)
1321
  else:
1322
  try:
1323
  with st.spinner("단기 예측 모델 생성 중..."):
1324
+ if use_ensemble:
1325
+ fc_micro = fit_ensemble_model(micro_df, selected_item, MICRO_END)
1326
+ else:
1327
+ fc_micro = fit_optimal_model(micro_df, selected_item, MICRO_END)
1328
 
1329
+ if fc_micro is not None:
1330
  # 2024-01-01부터 2026-12-31까지 필터링
1331
  start_date = pd.Timestamp("2024-01-01")
1332
  end_date = pd.Timestamp("2026-12-31")
 
1400
  name="95% 신뢰 구간"
1401
  ))
1402
 
1403
+ # 음수 예측값 제거
1404
+ fig.update_yaxes(range=[0, None])
1405
+
1406
  # 레이아웃 설정
1407
  fig.update_layout(
1408
  title=f"{selected_item} 월별 단기 예측 (2024-2026)",
 
1463
  # -------------------------------------------------
1464
  # SEASONALITY & PATTERN ---------------------------
1465
  # -------------------------------------------------
1466
+ if 'fc_micro' in locals() and fc_micro is not None:
1467
+ with st.expander("📆 시즈널리티 & 패턴 설명"):
1468
  try:
1469
+ # 월별 계절성 분석
1470
+ if "yearly" in fc_micro.columns and fc_micro["yearly"].sum() != 0:
1471
+ month_season = fc_micro.copy()
1472
+ month_season["month"] = month_season["ds"].dt.month
1473
+ month_seasonality = month_season.groupby("month")["yearly"].mean()
1474
+
1475
+ # 월 이름 설정
1476
+ month_names = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"]
1477
+
1478
+ # 계절성 차트 그리기
1479
+ fig = go.Figure()
1480
+ fig.add_trace(go.Bar(
1481
+ x=month_names,
1482
+ y=month_seasonality.values,
1483
+ marker_color=['blue' if x >= 0 else 'red' for x in month_seasonality.values]
1484
+ ))
1485
+
1486
+ fig.update_layout(
1487
+ title=f"{selected_item} 월별 계절성 패턴",
1488
+ xaxis_title="월",
1489
+ yaxis_title="상대적 가격 변동",
1490
+ )
1491
+
1492
+ st.plotly_chart(fig, use_container_width=True)
1493
+
1494
+ # 피크와 저점 계산
1495
+ peak_month = month_seasonality.idxmax()
1496
+ low_month = month_seasonality.idxmin()
1497
+ seasonality_range = month_seasonality.max() - month_seasonality.min()
1498
+
1499
+ st.markdown(
1500
+ f"**연간 피크 월:** {month_names[peak_month-1]} \n"
1501
+ f"**연간 저점 월:** {month_names[low_month-1]} \n"
1502
+ f"**연간 변동폭:** {seasonality_range:.1f}")
1503
+
1504
+ # 계절성이 높은 품목인지 설명
1505
+ if abs(seasonality_range) > 30:
1506
+ st.info(f"{selected_item}은(는) 계절성이 매우 강한 품목입니다. 특정 달에 가격이 크게 변동할 수 있습니다.")
1507
+ elif abs(seasonality_range) > 10:
1508
+ st.info(f"{selected_item}은(는) 계절성이 중간 정도인 품목입니다.")
1509
+ else:
1510
+ st.info(f"{selected_item}은(는) 계절성이 약한 품목입니다. 연중 가격이 비교적 안정적입니다.")
1511
  except Exception as e:
1512
+ st.error(f"계절성 분석 오류: {str(e)}")
1513
+ st.info("이 품목에 대한 계절성 패턴을 분석할 수 없습니다.")
 
1514
 
1515
  # -------------------------------------------------
1516
  # FOOTER ------------------------------------------