fmegahed commited on
Commit
fb5cba4
·
verified ·
1 Parent(s): 9ab8cf5

Upload the app and the requirements

Browse files
Files changed (2) hide show
  1. app.py +443 -0
  2. requirements.txt +5 -0
app.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def build_app() -> gr.Blocks:
262
+ theme = gr.themes.Soft(
263
+ primary_hue=gr.themes.Color(
264
+ c50="#eafaf9", c100="#d4f5f3", c200="#aaecea",
265
+ c300="#84d6d3", c400="#5ec4c0", c500="#3eaea9",
266
+ c600="#2e938e", c700="#237873", c800="#1a5d59",
267
+ c900="#12423f", c950="#0a2725",
268
+ ),
269
+ secondary_hue=gr.themes.Color(
270
+ c50="#fef2f3", c100="#fde6e8", c200="#fbd0d5",
271
+ c300="#f7a4ae", c400="#f17182", c500="#C3142D",
272
+ c600="#b01228", c700="#8B0E1E", c800="#6e0b18",
273
+ c900="#5c0d17", c950="#33040a",
274
+ ),
275
+ font=["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
276
+ )
277
+
278
+ with gr.Blocks(
279
+ title="Decomposition Explorer v1.0",
280
+ theme=theme,
281
+ css="""
282
+ .gradio-container { max-width: 1280px !important; margin: auto; }
283
+ footer { display: none !important; }
284
+ .gr-button-primary { background: #C3142D !important; border: none !important; }
285
+ .gr-button-primary:hover { background: #8B0E1E !important; }
286
+ .gr-button-secondary { border-color: #84d6d3 !important; color: #84d6d3 !important; }
287
+ .gr-button-secondary:hover { background: #84d6d3 !important; color: white !important; }
288
+ .gr-input:focus { border-color: #84d6d3 !important; box-shadow: 0 0 0 2px rgba(132,214,211,0.2) !important; }
289
+ """,
290
+ ) as app:
291
+ gr.HTML("""
292
+ <div style="display: flex; align-items: center; gap: 16px; padding: 16px 24px;
293
+ background: linear-gradient(135deg, #C3142D 0%, #8B0E1E 100%);
294
+ border-radius: 12px; margin-bottom: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
295
+ <img src="https://miamioh.edu/miami-brand/_files/images/system/logo-usage/minimum-size/beveled-m-min-size.png"
296
+ alt="Miami University" style="height: 56px; filter: brightness(0) invert(1);">
297
+ <div>
298
+ <h1 style="margin: 0; color: white; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">
299
+ Decomposition Explorer v1.0
300
+ </h1>
301
+ <p style="margin: 4px 0 0; color: rgba(255,255,255,0.85); font-size: 14px;">
302
+ ISA 444: Business Forecasting &middot; Farmer School of Business &middot; Miami University
303
+ </p>
304
+ </div>
305
+ </div>
306
+ """)
307
+
308
+ gr.HTML("""
309
+ <div style="background: #f8f9fa; border-left: 4px solid #84d6d3; padding: 12px 16px;
310
+ border-radius: 0 8px 8px 0; margin-bottom: 16px; font-size: 14px; color: #585E60;">
311
+ Interactive tool for exploring time-series decomposition methods (Classical and STL).
312
+ Choose a built-in dataset or upload your own CSV, adjust decomposition parameters, and
313
+ examine trend, seasonal, and remainder components along with strength measures.
314
+ </div>
315
+ """)
316
+
317
+ with gr.Row():
318
+ # --- Left column: controls ------------------------------------
319
+ with gr.Column(scale=1, min_width=280):
320
+ dataset_dd = gr.Dropdown(
321
+ label="Dataset",
322
+ choices=list(BUILTIN_DATASETS.keys()),
323
+ value="Airline Passengers",
324
+ )
325
+ csv_upload = gr.File(
326
+ label="Or upload CSV (columns: ds, y)",
327
+ file_types=[".csv"],
328
+ type="filepath",
329
+ )
330
+ method_radio = gr.Radio(
331
+ label="Decomposition Method",
332
+ choices=[
333
+ "Classical (Additive)",
334
+ "Classical (Multiplicative)",
335
+ "STL",
336
+ ],
337
+ value="STL",
338
+ )
339
+ period_slider = gr.Slider(
340
+ label="Period / Season Length",
341
+ minimum=2,
342
+ maximum=52,
343
+ step=1,
344
+ value=12,
345
+ )
346
+
347
+ # STL-specific controls
348
+ stl_group = gr.Group(visible=True)
349
+ with stl_group:
350
+ gr.Markdown("**STL Parameters**")
351
+ stl_seasonal_slider = gr.Slider(
352
+ label="seasonal (seasonality window, odd)",
353
+ minimum=7,
354
+ maximum=51,
355
+ step=2,
356
+ value=13,
357
+ )
358
+ stl_trend_slider = gr.Slider(
359
+ label="trend (trend window, odd; 0 = auto)",
360
+ minimum=0,
361
+ maximum=101,
362
+ step=2,
363
+ value=0,
364
+ )
365
+ stl_robust_cb = gr.Checkbox(
366
+ label="robust (robust to outliers)",
367
+ value=False,
368
+ )
369
+
370
+ # --- Right column: output -------------------------------------
371
+ with gr.Column(scale=3):
372
+ plot_output = gr.Plot(label="Decomposition")
373
+ summary_box = gr.Textbox(
374
+ label="Strength Measures",
375
+ lines=5,
376
+ interactive=False,
377
+ )
378
+
379
+ # --- Visibility toggle for STL controls ---------------------------
380
+ def toggle_stl(method):
381
+ return gr.Group(visible=(method == "STL"))
382
+
383
+ method_radio.change(
384
+ fn=toggle_stl,
385
+ inputs=[method_radio],
386
+ outputs=[stl_group],
387
+ )
388
+
389
+ # --- Gather all inputs --------------------------------------------
390
+ all_inputs = [
391
+ dataset_dd,
392
+ csv_upload,
393
+ method_radio,
394
+ period_slider,
395
+ stl_seasonal_slider,
396
+ stl_trend_slider,
397
+ stl_robust_cb,
398
+ ]
399
+ all_outputs = [plot_output, summary_box]
400
+
401
+ # --- Wire change events -------------------------------------------
402
+ for ctrl in all_inputs:
403
+ ctrl.change(
404
+ fn=decompose_and_plot,
405
+ inputs=all_inputs,
406
+ outputs=all_outputs,
407
+ )
408
+
409
+ # --- Initial load -------------------------------------------------
410
+ app.load(
411
+ fn=decompose_and_plot,
412
+ inputs=all_inputs,
413
+ outputs=all_outputs,
414
+ )
415
+
416
+ gr.HTML("""
417
+ <div style="margin-top: 24px; padding: 16px; background: #f8f9fa; border-radius: 8px;
418
+ text-align: center; font-size: 13px; color: #585E60; border-top: 2px solid #84d6d3;">
419
+ <div style="margin-bottom: 4px;">
420
+ <strong style="color: #C3142D;">Developed by</strong>
421
+ <a href="https://miamioh.edu/fsb/directory/?up=/directory/megahefm"
422
+ style="color: #84d6d3; text-decoration: none; font-weight: 600;">
423
+ Fadel M. Megahed
424
+ </a>
425
+ &middot; Gloss Professor of Analytics &middot; Miami University
426
+ </div>
427
+ <div style="font-size: 12px; color: #888;">
428
+ Version 1.0.0 &middot; Spring 2026 &middot;
429
+ <a href="https://github.com/fmegahed" style="color: #84d6d3; text-decoration: none;">GitHub</a> &middot;
430
+ <a href="https://www.linkedin.com/in/fmegahed/" style="color: #84d6d3; text-decoration: none;">LinkedIn</a>
431
+ </div>
432
+ </div>
433
+ """)
434
+
435
+ return app
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Entry point
440
+ # ---------------------------------------------------------------------------
441
+ if __name__ == "__main__":
442
+ demo = build_app()
443
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.0
2
+ pandas
3
+ numpy
4
+ matplotlib
5
+ statsmodels