fmegahed commited on
Commit
ba42222
·
verified ·
1 Parent(s): 7db227a

trying to fix logo to show

Browse files
Files changed (1) hide show
  1. app.py +448 -448
app.py CHANGED
@@ -1,448 +1,448 @@
1
- """
2
- Decomposition Explorer
3
- Interactive tool for exploring time-series decomposition methods.
4
- Part of ISA 444: Business Forecasting at Miami University (Spring 2026).
5
-
6
- Deployed to HuggingFace Spaces as fmegahed/decomposition-explorer.
7
- """
8
-
9
- import io
10
- import warnings
11
-
12
- import gradio as gr
13
- import matplotlib
14
- matplotlib.use("Agg")
15
- import matplotlib.pyplot as plt
16
- import numpy as np
17
- import pandas as pd
18
- from statsmodels.tsa.seasonal import STL, seasonal_decompose
19
-
20
- # ---------------------------------------------------------------------------
21
- # Color palette
22
- # ---------------------------------------------------------------------------
23
- CLR_PRIMARY = "#84d6d3" # teal
24
- CLR_ACCENT = "#C3142D" # Miami red
25
- CLR_TREND = "#C3142D"
26
- CLR_SEASON = "#84d6d3"
27
- CLR_RESID = "#666666"
28
-
29
- # ---------------------------------------------------------------------------
30
- # Built-in datasets
31
- # ---------------------------------------------------------------------------
32
-
33
- def _airline_passengers() -> pd.DataFrame:
34
- """Classic Box-Jenkins airline passengers (1949-1960, monthly)."""
35
- try:
36
- from statsmodels.datasets import co2 # noqa: F401
37
- import statsmodels.api as sm
38
- data = sm.datasets.get_rdataset("AirPassengers", "datasets").data
39
- dates = pd.date_range(start="1949-01-01", periods=len(data), freq="MS")
40
- return pd.DataFrame({"ds": dates, "y": data.iloc[:, 0].values})
41
- except Exception:
42
- # Fallback: generate the well-known series manually
43
- np.random.seed(0)
44
- dates = pd.date_range("1949-01-01", "1960-12-01", freq="MS")
45
- n = len(dates)
46
- t = np.arange(n)
47
- trend = 110 + 2.5 * t
48
- seasonal_pattern = np.array(
49
- [-24, -20, 2, -1, -5, 30, 47, 46, 14, -10, -25, -26]
50
- )
51
- season = np.tile(seasonal_pattern, n // 12 + 1)[:n]
52
- noise = np.random.normal(0, 6, n)
53
- y = trend + season * (1 + 0.02 * t) + noise
54
- return pd.DataFrame({"ds": dates, "y": np.round(y, 1)})
55
-
56
-
57
- def _us_retail_employment() -> pd.DataFrame:
58
- """Realistic synthetic monthly US retail employment (2000-2024)."""
59
- np.random.seed(42)
60
- dates = pd.date_range("2000-01-01", "2024-12-01", freq="MS")
61
- n = len(dates)
62
- t = np.arange(n)
63
-
64
- # Trend: upward with dips around 2008-09 and 2020
65
- trend = 15_000 + 12 * t
66
- # 2008-2009 recession dip
67
- recession_08 = -1400 * np.exp(-0.5 * ((t - 108) / 8) ** 2)
68
- # 2020 COVID dip
69
- covid_20 = -2800 * np.exp(-0.5 * ((t - 243) / 3) ** 2)
70
- trend = trend + recession_08 + covid_20
71
-
72
- # Seasonal pattern (retail peaks in Nov-Dec)
73
- seasonal_pattern = np.array(
74
- [-200, -350, -100, 50, 100, 150, 100, 80, -50, -100, 250, 500]
75
- )
76
- season = np.tile(seasonal_pattern, n // 12 + 1)[:n]
77
-
78
- noise = np.random.normal(0, 60, n)
79
- y = trend + season + noise
80
- return pd.DataFrame({"ds": dates, "y": np.round(y, 1)})
81
-
82
-
83
- def _ohio_nonfarm() -> pd.DataFrame:
84
- """Realistic synthetic monthly Ohio nonfarm employment (2010-2024)."""
85
- np.random.seed(7)
86
- dates = pd.date_range("2010-01-01", "2024-12-01", freq="MS")
87
- n = len(dates)
88
- t = np.arange(n)
89
-
90
- trend = 5_100 + 4.5 * t
91
- # COVID dip
92
- covid = -650 * np.exp(-0.5 * ((t - 123) / 3) ** 2)
93
- trend = trend + covid
94
-
95
- seasonal_pattern = np.array(
96
- [-80, -50, 30, 50, 70, 60, 20, 10, 30, 20, -30, -60]
97
- )
98
- season = np.tile(seasonal_pattern, n // 12 + 1)[:n]
99
-
100
- noise = np.random.normal(0, 25, n)
101
- y = trend + season + noise
102
- return pd.DataFrame({"ds": dates, "y": np.round(y, 1)})
103
-
104
-
105
- BUILTIN_DATASETS = {
106
- "Airline Passengers": _airline_passengers,
107
- "US Retail Employment": _us_retail_employment,
108
- "Ohio Nonfarm Employment": _ohio_nonfarm,
109
- }
110
-
111
- # ---------------------------------------------------------------------------
112
- # Helpers
113
- # ---------------------------------------------------------------------------
114
-
115
- def _load_dataset(name: str, csv_file) -> pd.DataFrame:
116
- """Return a DataFrame with columns ds (datetime) and y (float)."""
117
- if csv_file is not None:
118
- try:
119
- raw = pd.read_csv(csv_file.name if hasattr(csv_file, "name") else csv_file)
120
- if "ds" not in raw.columns or "y" not in raw.columns:
121
- raise ValueError("CSV must contain columns 'ds' and 'y'.")
122
- raw["ds"] = pd.to_datetime(raw["ds"])
123
- raw["y"] = pd.to_numeric(raw["y"], errors="coerce")
124
- raw = raw.dropna(subset=["y"]).sort_values("ds").reset_index(drop=True)
125
- return raw
126
- except Exception as exc:
127
- raise gr.Error(f"Could not read uploaded CSV: {exc}")
128
- if name in BUILTIN_DATASETS:
129
- return BUILTIN_DATASETS[name]()
130
- raise gr.Error(f"Unknown dataset: {name}")
131
-
132
-
133
- def _ensure_odd(val: int) -> int:
134
- """Force a value to be odd (required by statsmodels windows)."""
135
- val = int(val)
136
- return val if val % 2 == 1 else val + 1
137
-
138
-
139
- def _strength(residual: np.ndarray, component_plus_residual: np.ndarray) -> float:
140
- """Compute strength of a component: max(0, 1 - Var(R)/Var(C+R))."""
141
- var_r = np.nanvar(residual)
142
- var_cr = np.nanvar(component_plus_residual)
143
- if var_cr == 0:
144
- return 0.0
145
- return float(max(0.0, 1.0 - var_r / var_cr))
146
-
147
- # ---------------------------------------------------------------------------
148
- # Core decomposition + plotting
149
- # ---------------------------------------------------------------------------
150
-
151
- def decompose_and_plot(
152
- dataset_name: str,
153
- csv_file,
154
- method: str,
155
- period: int,
156
- stl_seasonal: int,
157
- stl_trend: int,
158
- stl_robust: bool,
159
- ):
160
- """Run decomposition and return (matplotlib Figure, summary string)."""
161
-
162
- # --- Load data --------------------------------------------------------
163
- df = _load_dataset(dataset_name, csv_file)
164
-
165
- if len(df) < 2 * period:
166
- raise gr.Error(
167
- f"Not enough observations ({len(df)}) for the chosen period ({period}). "
168
- f"Need at least {2 * period} observations."
169
- )
170
-
171
- y_series = pd.Series(df["y"].values, index=df["ds"])
172
-
173
- # --- Decompose --------------------------------------------------------
174
- with warnings.catch_warnings():
175
- warnings.simplefilter("ignore")
176
-
177
- if method == "STL":
178
- stl_seasonal = _ensure_odd(stl_seasonal)
179
- stl_trend_val = _ensure_odd(stl_trend) if stl_trend > 0 else None
180
- stl_obj = STL(
181
- y_series,
182
- period=int(period),
183
- seasonal=stl_seasonal,
184
- trend=stl_trend_val,
185
- robust=bool(stl_robust),
186
- )
187
- result = stl_obj.fit()
188
- else:
189
- model_type = "additive" if "Additive" in method else "multiplicative"
190
- result = seasonal_decompose(
191
- y_series, model=model_type, period=int(period)
192
- )
193
-
194
- observed = result.observed
195
- trend = result.trend
196
- seasonal = result.seasonal
197
- resid = result.resid
198
-
199
- # --- Strength measures ------------------------------------------------
200
- r = resid.values
201
- t = trend.values
202
- s = seasonal.values
203
- mask = ~(np.isnan(r) | np.isnan(t) | np.isnan(s))
204
- r_clean = r[mask]
205
- t_clean = t[mask]
206
- s_clean = s[mask]
207
-
208
- f_trend = _strength(r_clean, t_clean + r_clean)
209
- f_season = _strength(r_clean, s_clean + r_clean)
210
-
211
- # --- Plot -------------------------------------------------------------
212
- fig, axes = plt.subplots(4, 1, figsize=(10, 8), sharex=True)
213
- fig.patch.set_facecolor("white")
214
- for ax in axes:
215
- ax.set_facecolor("white")
216
- ax.grid(True, linewidth=0.3, alpha=0.5)
217
-
218
- dates = observed.index
219
-
220
- # 1. Observed
221
- axes[0].plot(dates, observed, color=CLR_PRIMARY, linewidth=1.2)
222
- axes[0].set_ylabel("Observed", fontsize=10, fontweight="bold")
223
-
224
- # 2. Trend
225
- axes[1].plot(dates, trend, color=CLR_TREND, linewidth=1.4)
226
- axes[1].set_ylabel("Trend", fontsize=10, fontweight="bold")
227
-
228
- # 3. Seasonal
229
- axes[2].plot(dates, seasonal, color=CLR_SEASON, linewidth=1.0)
230
- axes[2].set_ylabel("Seasonal", fontsize=10, fontweight="bold")
231
-
232
- # 4. Residual
233
- axes[3].plot(dates, resid, color=CLR_RESID, linewidth=0.8, alpha=0.8)
234
- axes[3].set_ylabel("Remainder", fontsize=10, fontweight="bold")
235
- axes[3].set_xlabel("Date", fontsize=10)
236
-
237
- method_label = method if method == "STL" else method.replace("Classical ", "Classical – ")
238
- fig.suptitle(
239
- f"Decomposition · {method_label} · period = {period}",
240
- fontsize=13,
241
- fontweight="bold",
242
- y=0.98,
243
- )
244
- fig.tight_layout(rect=[0, 0, 1, 0.96])
245
-
246
- # --- Summary text -----------------------------------------------------
247
- summary = (
248
- f"Strength of Trend (F_T): {f_trend:.4f}\n"
249
- f"Strength of Seasonality (F_S): {f_season:.4f}\n\n"
250
- f"Formulas:\n"
251
- f" F_T = max(0, 1 − Var(R) / Var(T + R))\n"
252
- f" F_S = max(0, 1 − Var(R) / Var(S + R))"
253
- )
254
-
255
- return fig, summary
256
-
257
- # ---------------------------------------------------------------------------
258
- # Gradio UI
259
- # ---------------------------------------------------------------------------
260
-
261
- _THEME = gr.themes.Soft(
262
- primary_hue=gr.themes.Color(
263
- c50="#fef2f3", c100="#fde6e8", c200="#fbd0d5",
264
- c300="#f7a4ae", c400="#f17182", c500="#C3142D",
265
- c600="#b01228", c700="#8B0E1E", c800="#6e0b18",
266
- c900="#5c0d17", c950="#33040a",
267
- ),
268
- secondary_hue=gr.themes.Color(
269
- c50="#fef2f3", c100="#fde6e8", c200="#fbd0d5",
270
- c300="#f7a4ae", c400="#f17182", c500="#C3142D",
271
- c600="#b01228", c700="#8B0E1E", c800="#6e0b18",
272
- c900="#5c0d17", c950="#33040a",
273
- ),
274
- neutral_hue=gr.themes.Color(
275
- c50="#EDECE2", c100="#E5E4D9", c200="#DDDCD0",
276
- c300="#C8C7BC", c400="#A3A299", c500="#858479",
277
- c600="#6B6A61", c700="#53524B", c800="#3B3A35",
278
- c900="#252420", c950="#151410",
279
- ),
280
- font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
281
- )
282
-
283
- _CSS = """
284
- .gradio-container { max-width: 1280px !important; margin: auto; }
285
- footer { display: none !important; }
286
- .gr-button-primary { background: #C3142D !important; border: none !important; }
287
- .gr-button-primary:hover { background: #8B0E1E !important; }
288
- .gr-button-secondary { border-color: #C3142D !important; color: #C3142D !important; }
289
- .gr-button-secondary:hover { background: #8B0E1E !important; color: white !important; }
290
- .gr-input:focus { border-color: #C3142D !important; box-shadow: 0 0 0 2px rgba(195,20,45,0.2) !important; }
291
- """
292
-
293
-
294
- def build_app() -> gr.Blocks:
295
- with gr.Blocks(title="Decomposition Explorer v1.0") as app:
296
- gr.HTML("""
297
- <div style="display: flex; align-items: center; gap: 16px; padding: 16px 24px;
298
- background: linear-gradient(135deg, #C3142D 0%, #8B0E1E 100%);
299
- border-radius: 12px; margin-bottom: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
300
- <img src="/file=beveled-m-min-size.png"
301
- alt="Miami University" style="height: 56px; filter: brightness(0) invert(1);">
302
- <div>
303
- <h1 style="margin: 0; color: white; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">
304
- Decomposition Explorer v1.0
305
- </h1>
306
- <p style="margin: 4px 0 0; color: rgba(255,255,255,0.85); font-size: 14px;">
307
- ISA 444: Business Forecasting &middot; Farmer School of Business &middot; Miami University
308
- </p>
309
- </div>
310
- </div>
311
- """)
312
-
313
- gr.HTML("""
314
- <div style="background: #EDECE2; border-left: 4px solid #C3142D; padding: 12px 16px;
315
- border-radius: 0 8px 8px 0; margin-bottom: 16px; font-size: 14px; color: #585E60;">
316
- Interactive tool for exploring time-series decomposition methods (Classical and STL).
317
- Choose a built-in dataset or upload your own CSV, adjust decomposition parameters, and
318
- examine trend, seasonal, and remainder components along with strength measures.
319
- </div>
320
- """)
321
-
322
- with gr.Row():
323
- # --- Left column: controls ------------------------------------
324
- with gr.Column(scale=1, min_width=280):
325
- dataset_dd = gr.Dropdown(
326
- label="Dataset",
327
- choices=list(BUILTIN_DATASETS.keys()),
328
- value="Airline Passengers",
329
- )
330
- csv_upload = gr.File(
331
- label="Or upload CSV (columns: ds, y)",
332
- file_types=[".csv"],
333
- type="filepath",
334
- )
335
- method_radio = gr.Radio(
336
- label="Decomposition Method",
337
- choices=[
338
- "Classical (Additive)",
339
- "Classical (Multiplicative)",
340
- "STL",
341
- ],
342
- value="STL",
343
- )
344
- period_slider = gr.Slider(
345
- label="Period / Season Length",
346
- minimum=2,
347
- maximum=52,
348
- step=1,
349
- value=12,
350
- )
351
-
352
- # STL-specific controls
353
- stl_group = gr.Group(visible=True)
354
- with stl_group:
355
- gr.Markdown("**STL Parameters**")
356
- stl_seasonal_slider = gr.Slider(
357
- label="seasonal (seasonality window, odd)",
358
- minimum=7,
359
- maximum=51,
360
- step=2,
361
- value=13,
362
- )
363
- stl_trend_slider = gr.Slider(
364
- label="trend (trend window, odd; 0 = auto)",
365
- minimum=0,
366
- maximum=101,
367
- step=2,
368
- value=0,
369
- )
370
- stl_robust_cb = gr.Checkbox(
371
- label="robust (robust to outliers)",
372
- value=False,
373
- )
374
-
375
- # --- Right column: output -------------------------------------
376
- with gr.Column(scale=3):
377
- plot_output = gr.Plot(label="Decomposition")
378
- summary_box = gr.Textbox(
379
- label="Strength Measures",
380
- lines=5,
381
- interactive=False,
382
- )
383
-
384
- # --- Visibility toggle for STL controls ---------------------------
385
- def toggle_stl(method):
386
- return gr.Group(visible=(method == "STL"))
387
-
388
- method_radio.change(
389
- fn=toggle_stl,
390
- inputs=[method_radio],
391
- outputs=[stl_group],
392
- )
393
-
394
- # --- Gather all inputs --------------------------------------------
395
- all_inputs = [
396
- dataset_dd,
397
- csv_upload,
398
- method_radio,
399
- period_slider,
400
- stl_seasonal_slider,
401
- stl_trend_slider,
402
- stl_robust_cb,
403
- ]
404
- all_outputs = [plot_output, summary_box]
405
-
406
- # --- Wire change events -------------------------------------------
407
- for ctrl in all_inputs:
408
- ctrl.change(
409
- fn=decompose_and_plot,
410
- inputs=all_inputs,
411
- outputs=all_outputs,
412
- )
413
-
414
- # --- Initial load -------------------------------------------------
415
- app.load(
416
- fn=decompose_and_plot,
417
- inputs=all_inputs,
418
- outputs=all_outputs,
419
- )
420
-
421
- gr.HTML("""
422
- <div style="margin-top: 24px; padding: 16px; background: #EDECE2; border-radius: 8px;
423
- text-align: center; font-size: 13px; color: #585E60; border-top: 2px solid #C3142D;">
424
- <div style="margin-bottom: 4px;">
425
- <strong style="color: #C3142D;">Developed by</strong>
426
- <a href="https://miamioh.edu/fsb/directory/?up=/directory/megahefm"
427
- style="color: #C3142D; text-decoration: none; font-weight: 600;">
428
- Fadel M. Megahed
429
- </a>
430
- &middot; Gloss Professor of Analytics &middot; Miami University
431
- </div>
432
- <div style="font-size: 12px; color: #888;">
433
- Version 1.0.0 &middot; Spring 2026 &middot;
434
- <a href="https://github.com/fmegahed" style="color: #C3142D; text-decoration: none;">GitHub</a> &middot;
435
- <a href="https://www.linkedin.com/in/fmegahed/" style="color: #C3142D; text-decoration: none;">LinkedIn</a>
436
- </div>
437
- </div>
438
- """)
439
-
440
- return app
441
-
442
-
443
- # ---------------------------------------------------------------------------
444
- # Entry point
445
- # ---------------------------------------------------------------------------
446
- if __name__ == "__main__":
447
- demo = build_app()
448
- demo.launch(theme=_THEME, css=_CSS, ssr_mode=False, allowed_paths=["beveled-m-min-size.png"])
 
1
+ """
2
+ Decomposition Explorer
3
+ Interactive tool for exploring time-series decomposition methods.
4
+ Part of ISA 444: Business Forecasting at Miami University (Spring 2026).
5
+
6
+ Deployed to HuggingFace Spaces as fmegahed/decomposition-explorer.
7
+ """
8
+
9
+ import io
10
+ import warnings
11
+
12
+ import gradio as gr
13
+ import matplotlib
14
+ matplotlib.use("Agg")
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ import pandas as pd
18
+ from statsmodels.tsa.seasonal import STL, seasonal_decompose
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Color palette
22
+ # ---------------------------------------------------------------------------
23
+ CLR_PRIMARY = "#84d6d3" # teal
24
+ CLR_ACCENT = "#C3142D" # Miami red
25
+ CLR_TREND = "#C3142D"
26
+ CLR_SEASON = "#84d6d3"
27
+ CLR_RESID = "#666666"
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Built-in datasets
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def _airline_passengers() -> pd.DataFrame:
34
+ """Classic Box-Jenkins airline passengers (1949-1960, monthly)."""
35
+ try:
36
+ from statsmodels.datasets import co2 # noqa: F401
37
+ import statsmodels.api as sm
38
+ data = sm.datasets.get_rdataset("AirPassengers", "datasets").data
39
+ dates = pd.date_range(start="1949-01-01", periods=len(data), freq="MS")
40
+ return pd.DataFrame({"ds": dates, "y": data.iloc[:, 0].values})
41
+ except Exception:
42
+ # Fallback: generate the well-known series manually
43
+ np.random.seed(0)
44
+ dates = pd.date_range("1949-01-01", "1960-12-01", freq="MS")
45
+ n = len(dates)
46
+ t = np.arange(n)
47
+ trend = 110 + 2.5 * t
48
+ seasonal_pattern = np.array(
49
+ [-24, -20, 2, -1, -5, 30, 47, 46, 14, -10, -25, -26]
50
+ )
51
+ season = np.tile(seasonal_pattern, n // 12 + 1)[:n]
52
+ noise = np.random.normal(0, 6, n)
53
+ y = trend + season * (1 + 0.02 * t) + noise
54
+ return pd.DataFrame({"ds": dates, "y": np.round(y, 1)})
55
+
56
+
57
+ def _us_retail_employment() -> pd.DataFrame:
58
+ """Realistic synthetic monthly US retail employment (2000-2024)."""
59
+ np.random.seed(42)
60
+ dates = pd.date_range("2000-01-01", "2024-12-01", freq="MS")
61
+ n = len(dates)
62
+ t = np.arange(n)
63
+
64
+ # Trend: upward with dips around 2008-09 and 2020
65
+ trend = 15_000 + 12 * t
66
+ # 2008-2009 recession dip
67
+ recession_08 = -1400 * np.exp(-0.5 * ((t - 108) / 8) ** 2)
68
+ # 2020 COVID dip
69
+ covid_20 = -2800 * np.exp(-0.5 * ((t - 243) / 3) ** 2)
70
+ trend = trend + recession_08 + covid_20
71
+
72
+ # Seasonal pattern (retail peaks in Nov-Dec)
73
+ seasonal_pattern = np.array(
74
+ [-200, -350, -100, 50, 100, 150, 100, 80, -50, -100, 250, 500]
75
+ )
76
+ season = np.tile(seasonal_pattern, n // 12 + 1)[:n]
77
+
78
+ noise = np.random.normal(0, 60, n)
79
+ y = trend + season + noise
80
+ return pd.DataFrame({"ds": dates, "y": np.round(y, 1)})
81
+
82
+
83
+ def _ohio_nonfarm() -> pd.DataFrame:
84
+ """Realistic synthetic monthly Ohio nonfarm employment (2010-2024)."""
85
+ np.random.seed(7)
86
+ dates = pd.date_range("2010-01-01", "2024-12-01", freq="MS")
87
+ n = len(dates)
88
+ t = np.arange(n)
89
+
90
+ trend = 5_100 + 4.5 * t
91
+ # COVID dip
92
+ covid = -650 * np.exp(-0.5 * ((t - 123) / 3) ** 2)
93
+ trend = trend + covid
94
+
95
+ seasonal_pattern = np.array(
96
+ [-80, -50, 30, 50, 70, 60, 20, 10, 30, 20, -30, -60]
97
+ )
98
+ season = np.tile(seasonal_pattern, n // 12 + 1)[:n]
99
+
100
+ noise = np.random.normal(0, 25, n)
101
+ y = trend + season + noise
102
+ return pd.DataFrame({"ds": dates, "y": np.round(y, 1)})
103
+
104
+
105
+ BUILTIN_DATASETS = {
106
+ "Airline Passengers": _airline_passengers,
107
+ "US Retail Employment": _us_retail_employment,
108
+ "Ohio Nonfarm Employment": _ohio_nonfarm,
109
+ }
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Helpers
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _load_dataset(name: str, csv_file) -> pd.DataFrame:
116
+ """Return a DataFrame with columns ds (datetime) and y (float)."""
117
+ if csv_file is not None:
118
+ try:
119
+ raw = pd.read_csv(csv_file.name if hasattr(csv_file, "name") else csv_file)
120
+ if "ds" not in raw.columns or "y" not in raw.columns:
121
+ raise ValueError("CSV must contain columns 'ds' and 'y'.")
122
+ raw["ds"] = pd.to_datetime(raw["ds"])
123
+ raw["y"] = pd.to_numeric(raw["y"], errors="coerce")
124
+ raw = raw.dropna(subset=["y"]).sort_values("ds").reset_index(drop=True)
125
+ return raw
126
+ except Exception as exc:
127
+ raise gr.Error(f"Could not read uploaded CSV: {exc}")
128
+ if name in BUILTIN_DATASETS:
129
+ return BUILTIN_DATASETS[name]()
130
+ raise gr.Error(f"Unknown dataset: {name}")
131
+
132
+
133
+ def _ensure_odd(val: int) -> int:
134
+ """Force a value to be odd (required by statsmodels windows)."""
135
+ val = int(val)
136
+ return val if val % 2 == 1 else val + 1
137
+
138
+
139
+ def _strength(residual: np.ndarray, component_plus_residual: np.ndarray) -> float:
140
+ """Compute strength of a component: max(0, 1 - Var(R)/Var(C+R))."""
141
+ var_r = np.nanvar(residual)
142
+ var_cr = np.nanvar(component_plus_residual)
143
+ if var_cr == 0:
144
+ return 0.0
145
+ return float(max(0.0, 1.0 - var_r / var_cr))
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Core decomposition + plotting
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def decompose_and_plot(
152
+ dataset_name: str,
153
+ csv_file,
154
+ method: str,
155
+ period: int,
156
+ stl_seasonal: int,
157
+ stl_trend: int,
158
+ stl_robust: bool,
159
+ ):
160
+ """Run decomposition and return (matplotlib Figure, summary string)."""
161
+
162
+ # --- Load data --------------------------------------------------------
163
+ df = _load_dataset(dataset_name, csv_file)
164
+
165
+ if len(df) < 2 * period:
166
+ raise gr.Error(
167
+ f"Not enough observations ({len(df)}) for the chosen period ({period}). "
168
+ f"Need at least {2 * period} observations."
169
+ )
170
+
171
+ y_series = pd.Series(df["y"].values, index=df["ds"])
172
+
173
+ # --- Decompose --------------------------------------------------------
174
+ with warnings.catch_warnings():
175
+ warnings.simplefilter("ignore")
176
+
177
+ if method == "STL":
178
+ stl_seasonal = _ensure_odd(stl_seasonal)
179
+ stl_trend_val = _ensure_odd(stl_trend) if stl_trend > 0 else None
180
+ stl_obj = STL(
181
+ y_series,
182
+ period=int(period),
183
+ seasonal=stl_seasonal,
184
+ trend=stl_trend_val,
185
+ robust=bool(stl_robust),
186
+ )
187
+ result = stl_obj.fit()
188
+ else:
189
+ model_type = "additive" if "Additive" in method else "multiplicative"
190
+ result = seasonal_decompose(
191
+ y_series, model=model_type, period=int(period)
192
+ )
193
+
194
+ observed = result.observed
195
+ trend = result.trend
196
+ seasonal = result.seasonal
197
+ resid = result.resid
198
+
199
+ # --- Strength measures ------------------------------------------------
200
+ r = resid.values
201
+ t = trend.values
202
+ s = seasonal.values
203
+ mask = ~(np.isnan(r) | np.isnan(t) | np.isnan(s))
204
+ r_clean = r[mask]
205
+ t_clean = t[mask]
206
+ s_clean = s[mask]
207
+
208
+ f_trend = _strength(r_clean, t_clean + r_clean)
209
+ f_season = _strength(r_clean, s_clean + r_clean)
210
+
211
+ # --- Plot -------------------------------------------------------------
212
+ fig, axes = plt.subplots(4, 1, figsize=(10, 8), sharex=True)
213
+ fig.patch.set_facecolor("white")
214
+ for ax in axes:
215
+ ax.set_facecolor("white")
216
+ ax.grid(True, linewidth=0.3, alpha=0.5)
217
+
218
+ dates = observed.index
219
+
220
+ # 1. Observed
221
+ axes[0].plot(dates, observed, color=CLR_PRIMARY, linewidth=1.2)
222
+ axes[0].set_ylabel("Observed", fontsize=10, fontweight="bold")
223
+
224
+ # 2. Trend
225
+ axes[1].plot(dates, trend, color=CLR_TREND, linewidth=1.4)
226
+ axes[1].set_ylabel("Trend", fontsize=10, fontweight="bold")
227
+
228
+ # 3. Seasonal
229
+ axes[2].plot(dates, seasonal, color=CLR_SEASON, linewidth=1.0)
230
+ axes[2].set_ylabel("Seasonal", fontsize=10, fontweight="bold")
231
+
232
+ # 4. Residual
233
+ axes[3].plot(dates, resid, color=CLR_RESID, linewidth=0.8, alpha=0.8)
234
+ axes[3].set_ylabel("Remainder", fontsize=10, fontweight="bold")
235
+ axes[3].set_xlabel("Date", fontsize=10)
236
+
237
+ method_label = method if method == "STL" else method.replace("Classical ", "Classical – ")
238
+ fig.suptitle(
239
+ f"Decomposition · {method_label} · period = {period}",
240
+ fontsize=13,
241
+ fontweight="bold",
242
+ y=0.98,
243
+ )
244
+ fig.tight_layout(rect=[0, 0, 1, 0.96])
245
+
246
+ # --- Summary text -----------------------------------------------------
247
+ summary = (
248
+ f"Strength of Trend (F_T): {f_trend:.4f}\n"
249
+ f"Strength of Seasonality (F_S): {f_season:.4f}\n\n"
250
+ f"Formulas:\n"
251
+ f" F_T = max(0, 1 − Var(R) / Var(T + R))\n"
252
+ f" F_S = max(0, 1 − Var(R) / Var(S + R))"
253
+ )
254
+
255
+ return fig, summary
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Gradio UI
259
+ # ---------------------------------------------------------------------------
260
+
261
+ _THEME = gr.themes.Soft(
262
+ primary_hue=gr.themes.Color(
263
+ c50="#fef2f3", c100="#fde6e8", c200="#fbd0d5",
264
+ c300="#f7a4ae", c400="#f17182", c500="#C3142D",
265
+ c600="#b01228", c700="#8B0E1E", c800="#6e0b18",
266
+ c900="#5c0d17", c950="#33040a",
267
+ ),
268
+ secondary_hue=gr.themes.Color(
269
+ c50="#fef2f3", c100="#fde6e8", c200="#fbd0d5",
270
+ c300="#f7a4ae", c400="#f17182", c500="#C3142D",
271
+ c600="#b01228", c700="#8B0E1E", c800="#6e0b18",
272
+ c900="#5c0d17", c950="#33040a",
273
+ ),
274
+ neutral_hue=gr.themes.Color(
275
+ c50="#EDECE2", c100="#E5E4D9", c200="#DDDCD0",
276
+ c300="#C8C7BC", c400="#A3A299", c500="#858479",
277
+ c600="#6B6A61", c700="#53524B", c800="#3B3A35",
278
+ c900="#252420", c950="#151410",
279
+ ),
280
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
281
+ )
282
+
283
+ _CSS = """
284
+ .gradio-container { max-width: 1280px !important; margin: auto; }
285
+ footer { display: none !important; }
286
+ .gr-button-primary { background: #C3142D !important; border: none !important; }
287
+ .gr-button-primary:hover { background: #8B0E1E !important; }
288
+ .gr-button-secondary { border-color: #C3142D !important; color: #C3142D !important; }
289
+ .gr-button-secondary:hover { background: #8B0E1E !important; color: white !important; }
290
+ .gr-input:focus { border-color: #C3142D !important; box-shadow: 0 0 0 2px rgba(195,20,45,0.2) !important; }
291
+ """
292
+
293
+
294
+ def build_app() -> gr.Blocks:
295
+ with gr.Blocks(title="Decomposition Explorer v1.0") as app:
296
+ gr.HTML("""
297
+ <div style="display: flex; align-items: center; gap: 16px; padding: 16px 24px;
298
+ background: linear-gradient(135deg, #C3142D 0%, #8B0E1E 100%);
299
+ border-radius: 12px; margin-bottom: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
300
+ <img src="beveled-m-min-size.png"
301
+ alt="Miami University" style="height: 56px; filter: brightness(0) invert(1);">
302
+ <div>
303
+ <h1 style="margin: 0; color: white; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">
304
+ Decomposition Explorer v1.0
305
+ </h1>
306
+ <p style="margin: 4px 0 0; color: rgba(255,255,255,0.85); font-size: 14px;">
307
+ ISA 444: Business Forecasting &middot; Farmer School of Business &middot; Miami University
308
+ </p>
309
+ </div>
310
+ </div>
311
+ """)
312
+
313
+ gr.HTML("""
314
+ <div style="background: #EDECE2; border-left: 4px solid #C3142D; padding: 12px 16px;
315
+ border-radius: 0 8px 8px 0; margin-bottom: 16px; font-size: 14px; color: #585E60;">
316
+ Interactive tool for exploring time-series decomposition methods (Classical and STL).
317
+ Choose a built-in dataset or upload your own CSV, adjust decomposition parameters, and
318
+ examine trend, seasonal, and remainder components along with strength measures.
319
+ </div>
320
+ """)
321
+
322
+ with gr.Row():
323
+ # --- Left column: controls ------------------------------------
324
+ with gr.Column(scale=1, min_width=280):
325
+ dataset_dd = gr.Dropdown(
326
+ label="Dataset",
327
+ choices=list(BUILTIN_DATASETS.keys()),
328
+ value="Airline Passengers",
329
+ )
330
+ csv_upload = gr.File(
331
+ label="Or upload CSV (columns: ds, y)",
332
+ file_types=[".csv"],
333
+ type="filepath",
334
+ )
335
+ method_radio = gr.Radio(
336
+ label="Decomposition Method",
337
+ choices=[
338
+ "Classical (Additive)",
339
+ "Classical (Multiplicative)",
340
+ "STL",
341
+ ],
342
+ value="STL",
343
+ )
344
+ period_slider = gr.Slider(
345
+ label="Period / Season Length",
346
+ minimum=2,
347
+ maximum=52,
348
+ step=1,
349
+ value=12,
350
+ )
351
+
352
+ # STL-specific controls
353
+ stl_group = gr.Group(visible=True)
354
+ with stl_group:
355
+ gr.Markdown("**STL Parameters**")
356
+ stl_seasonal_slider = gr.Slider(
357
+ label="seasonal (seasonality window, odd)",
358
+ minimum=7,
359
+ maximum=51,
360
+ step=2,
361
+ value=13,
362
+ )
363
+ stl_trend_slider = gr.Slider(
364
+ label="trend (trend window, odd; 0 = auto)",
365
+ minimum=0,
366
+ maximum=101,
367
+ step=2,
368
+ value=0,
369
+ )
370
+ stl_robust_cb = gr.Checkbox(
371
+ label="robust (robust to outliers)",
372
+ value=False,
373
+ )
374
+
375
+ # --- Right column: output -------------------------------------
376
+ with gr.Column(scale=3):
377
+ plot_output = gr.Plot(label="Decomposition")
378
+ summary_box = gr.Textbox(
379
+ label="Strength Measures",
380
+ lines=5,
381
+ interactive=False,
382
+ )
383
+
384
+ # --- Visibility toggle for STL controls ---------------------------
385
+ def toggle_stl(method):
386
+ return gr.Group(visible=(method == "STL"))
387
+
388
+ method_radio.change(
389
+ fn=toggle_stl,
390
+ inputs=[method_radio],
391
+ outputs=[stl_group],
392
+ )
393
+
394
+ # --- Gather all inputs --------------------------------------------
395
+ all_inputs = [
396
+ dataset_dd,
397
+ csv_upload,
398
+ method_radio,
399
+ period_slider,
400
+ stl_seasonal_slider,
401
+ stl_trend_slider,
402
+ stl_robust_cb,
403
+ ]
404
+ all_outputs = [plot_output, summary_box]
405
+
406
+ # --- Wire change events -------------------------------------------
407
+ for ctrl in all_inputs:
408
+ ctrl.change(
409
+ fn=decompose_and_plot,
410
+ inputs=all_inputs,
411
+ outputs=all_outputs,
412
+ )
413
+
414
+ # --- Initial load -------------------------------------------------
415
+ app.load(
416
+ fn=decompose_and_plot,
417
+ inputs=all_inputs,
418
+ outputs=all_outputs,
419
+ )
420
+
421
+ gr.HTML("""
422
+ <div style="margin-top: 24px; padding: 16px; background: #EDECE2; border-radius: 8px;
423
+ text-align: center; font-size: 13px; color: #585E60; border-top: 2px solid #C3142D;">
424
+ <div style="margin-bottom: 4px;">
425
+ <strong style="color: #C3142D;">Developed by</strong>
426
+ <a href="https://miamioh.edu/fsb/directory/?up=/directory/megahefm"
427
+ style="color: #C3142D; text-decoration: none; font-weight: 600;">
428
+ Fadel M. Megahed
429
+ </a>
430
+ &middot; Gloss Professor of Analytics &middot; Miami University
431
+ </div>
432
+ <div style="font-size: 12px; color: #888;">
433
+ Version 1.0.0 &middot; Spring 2026 &middot;
434
+ <a href="https://github.com/fmegahed" style="color: #C3142D; text-decoration: none;">GitHub</a> &middot;
435
+ <a href="https://www.linkedin.com/in/fmegahed/" style="color: #C3142D; text-decoration: none;">LinkedIn</a>
436
+ </div>
437
+ </div>
438
+ """)
439
+
440
+ return app
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # Entry point
445
+ # ---------------------------------------------------------------------------
446
+ if __name__ == "__main__":
447
+ demo = build_app()
448
+ demo.launch(theme=_THEME, css=_CSS, ssr_mode=False, allowed_paths=["beveled-m-min-size.png"])