File size: 8,211 Bytes
4ae5386
 
9426f20
1ee3e1d
 
4ae5386
 
 
 
 
 
 
 
3a3bd67
4ae5386
 
3a3bd67
 
4ae5386
9426f20
 
4ae5386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ee3e1d
 
9426f20
1ee3e1d
 
4ae5386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9426f20
4ae5386
691db48
 
4ae5386
691db48
 
4ae5386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"""Gradio app: compare estimators of a smooth level on sparse, gappy air-quality data.

Reads the tables written by dataset/precompute.py: from DATA_DIR if set, else from an outputs/ folder
next to the app (on the Space) or at the repo root (run from the repo), otherwise from the Hugging Face dataset
HF_DATASET.
"""
import json
import os
from pathlib import Path

import gradio as gr
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots

pio.templates.default = "ggplot2"

HF_DATASET = "prteek/sparse-panel-demo"
BLOG_URL = "https://huggingface.co/blog/prteek/sparse-panel-modelling"
LAMBDA_NAMES = {0.5: "Sparse: ~0.5 hours a day", 1.2: "As in the blog post: ~1.2 hours a day", 3.0: "Rich: ~3 hours a day"}
LABEL_NOTES = {
    "regular": "Thinned at the global setting, with two 20–60 day gaps per pollutant.",
    "sparse": "Thinned to 40% of the global setting's hours.",
    "very sparse": "As regular, then only 15% of the observed days kept.",
    "long gap": "As regular, but the two gaps are 90–120 days long.",
    "dropped series": "One pollutant removed entirely; each panel shows the dropped one. "
                      "The joint network can rebuild it from the station's other pollutant.",
}
STYLE = {
    "joint": dict(color="#1f77b4", width=2),
    "single-target": dict(color="#9467bd", width=1.5, dash="dash"),
    "±15-day mean": dict(color="#ff7f0e", width=1.2),
}
DAY_TYPES = ["all", "observed", "unobserved", "gap"]
TRUTH_COLOR = "#2ca02c"  # visible on light and dark themes


def load() -> dict:
    here = Path(__file__).resolve().parent
    candidates = ([Path(os.environ["DATA_DIR"])] if "DATA_DIR" in os.environ
                  else [here / "outputs", here.parent / "outputs"])
    root = next((c for c in candidates if c.exists()), None)
    if root is None:
        from huggingface_hub import snapshot_download

        root = Path(snapshot_download(HF_DATASET, repo_type="dataset", allow_patterns=["*.parquet", "meta.json"]))
    tables = {f.stem: pd.read_parquet(f) for f in root.glob("*.parquet")}
    tables["meta"] = json.loads((root / "meta.json").read_text())
    return tables


def gap_spans(dates: pd.Series):
    d = dates.sort_values()
    block = (d.diff().dt.days != 1).cumsum()
    return d.groupby(block).agg(["min", "max"]).itertuples(index=False)


def figure(t: dict, lam: float, rows: pd.DataFrame) -> go.Figure:
    titles = [f"{r.station} · {r.pollutant} ({r.area})" for r in rows.itertuples()]
    fig = make_subplots(rows=len(rows), cols=1, shared_xaxes=True, subplot_titles=titles, vertical_spacing=0.06)
    readings = t["readings"][t["readings"]["lam"] == lam]
    preds = t["predictions"][t["predictions"]["lam"] == lam]
    for i, r in enumerate(rows.itertuples(), 1):
        sel = lambda d: d[(d["station"] == r.station) & (d["pollutant"] == r.pollutant)]
        first = i == 1
        s = sel(readings)
        fig.add_trace(go.Scatter(x=s["date"], y=s["value"], mode="markers", name="sparse readings",
                                 marker=dict(size=3 + 1.5 * s["n"], color="rgba(128,128,128,0.45)"),
                                 legendgroup="readings", showlegend=first), row=i, col=1)
        tr = sel(t["truth"])
        fig.add_trace(go.Scatter(x=tr["date"], y=tr["value"], mode="lines", name="31-day MA (truth)",
                                 line=dict(color=TRUTH_COLOR, width=2.5), legendgroup="truth", showlegend=first),
                      row=i, col=1)
        for name, style in STYLE.items():
            p = sel(preds[preds["estimator"] == name])
            fig.add_trace(go.Scatter(x=p["date"], y=p["pred"], mode="lines", name=name, line=style,
                                     legendgroup=name, showlegend=first), row=i, col=1)
        # after the traces: plotly skips shapes on subplots that have no traces yet
        for a, b in gap_spans(sel(t["gaps"])["date"]):
            fig.add_vrect(x0=a, x1=b, fillcolor="gold", opacity=0.25, line_width=0, layer="below", row=i, col=1)
        fig.update_yaxes(type="log", title_text="µg/m³", row=i, col=1)
    height, top = 260 * len(rows) + 110, 100
    fig.update_layout(height=height, margin=dict(l=10, r=10, t=top, b=10), hovermode="x unified",
                      # legend sits in the top margin, clear of the first subplot title
                      legend=dict(orientation="h", y=1 + 45 / (height - top - 10), yanchor="bottom", x=0))
    return fig


def error_table(m: pd.DataFrame) -> pd.DataFrame:
    tab = m.pivot_table(index="estimator", columns="day_type", values="error")
    days = m.groupby("day_type")["days"].max()
    cols = [c for c in DAY_TYPES if c in tab]
    tab = tab[cols].reindex(list(STYLE)).map(lambda v: f"{v:.3f}")
    tab.loc["days"] = [f"{days[c]:,}" for c in cols]  # counts as a row keeps the headers narrow
    return tab.reset_index()


def trend_figure(m: pd.DataFrame) -> go.Figure:
    trend = m[m["day_type"] == "all"].pivot_table(index="lam", columns="estimator", values="error")[list(STYLE)]
    fig = go.Figure([go.Scatter(x=trend.index, y=trend[c], mode="lines+markers", name=c,
                                line={k: v for k, v in STYLE[c].items() if k != "width"}) for c in trend])
    fig.update_layout(height=300, margin=dict(l=10, r=10, t=10, b=10), xaxis_title="mean hours kept per day",
                      yaxis_title="error", legend=dict(orientation="h", y=-0.3))
    return fig


T = load()
LAMBDAS = T["meta"]["lambdas"]


def view(lam: float, label: str, pollutant: str):
    group = T["labels"][T["labels"]["label"] == label]
    if label == "dropped series":
        pollutant = "dropped"
        rows = group.assign(pollutant=group["dropped"])
    else:
        rows = group.assign(pollutant=pollutant)
    m = T["metrics"][(T["metrics"]["label"] == label) & (T["metrics"]["pollutant"] == pollutant)]
    note = f"### {label.capitalize()} stations\n{LABEL_NOTES[label]} Shaded: gap blocks."
    return (note, figure(T, lam, rows), error_table(m[m["lam"] == lam]), trend_figure(m),
            gr.update(visible=label != "dropped series"))


with gr.Blocks(title="Sparse panel demo") as demo:
    gr.Markdown(
        "# Smooth levels from sparse, gappy data\n"
        "Hourly PM2.5 and PM10 from 12 Beijing monitoring stations are thinned to a few readings a day, with gaps, "
        "and three estimators try to recover the slow underlying level: a **joint network** that models both "
        "pollutants together, the same network fitted to **one pollutant at a time**, and a **±15-day mean** of the "
        f"readings. The truth is the 31-day moving average of the full data. Method and discussion: [blog post]({BLOG_URL})."
    )
    with gr.Row(equal_height=True):
        lam = gr.Radio([(LAMBDA_NAMES.get(l, str(l)), l) for l in LAMBDAS], value=1.2, scale=3,
                       label="Readings kept (Poisson mean hours per station-day)")
        label = gr.Dropdown(list(LABEL_NOTES), value="regular", label="Station group", scale=1)
        pollutant = gr.Radio(["PM2.5", "PM10"], value="PM2.5", label="Pollutant", scale=1)
    note = gr.Markdown()
    plot = gr.Plot(show_label=False)
    with gr.Row():
        with gr.Column(scale=3):
            gr.Markdown("**Error against the 31-day MA** (mean absolute log error; 0.15 ≈ 15%)")
            table = gr.Dataframe(interactive=False, show_label=False)
            gr.Markdown("observed: days with a reading · unobserved: no reading, outside the gaps · "
                        "gap: inside a gap block")
        with gr.Column(scale=2):
            gr.Markdown("**All days, by thinning level**")
            trend = gr.Plot(show_label=False)
    gr.Markdown("Data: Beijing Multi-Site Air Quality, UCI Machine Learning Repository (CC BY 4.0). "
                "Labels are assigned once with a fixed seed: each degraded group has one urban and one suburban "
                "station.")

    outputs = [note, plot, table, trend, pollutant]
    for control in (lam, label, pollutant):
        control.change(view, [lam, label, pollutant], outputs)
    demo.load(view, [lam, label, pollutant], outputs)

if __name__ == "__main__":
    demo.launch()