ZivKassnerNK commited on
Commit
aeb0038
·
verified ·
1 Parent(s): 562b396

Initial deploy v2

Browse files
Files changed (10) hide show
  1. .streamlit/config.toml +8 -0
  2. Dockerfile +32 -0
  3. README.md +78 -3
  4. app.py +309 -0
  5. charts.py +109 -0
  6. config.py +69 -0
  7. hf_client.py +123 -0
  8. parsing.py +136 -0
  9. requirements.txt +6 -0
  10. transforms.py +197 -0
.streamlit/config.toml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [server]
2
+ headless = true
3
+ port = 7860
4
+ enableCORS = false
5
+ enableXsrfProtection = false
6
+
7
+ [browser]
8
+ gatherUsageStats = false
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1
5
+
6
+ # Dev Mode requirements for Docker Spaces:
7
+ # bash, curl, wget, procps, git, git-lfs.
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ bash \
10
+ curl \
11
+ wget \
12
+ procps \
13
+ git \
14
+ git-lfs \
15
+ ca-certificates \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ RUN useradd --create-home --uid 1000 appuser
19
+
20
+ WORKDIR /app
21
+
22
+ COPY requirements.txt /app/requirements.txt
23
+ RUN pip install --upgrade pip && pip install -r /app/requirements.txt
24
+
25
+ COPY . /app
26
+ RUN chown -R 1000:1000 /app
27
+
28
+ USER 1000
29
+
30
+ EXPOSE 7860
31
+
32
+ CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableXsrfProtection=false", "--browser.gatherUsageStats=false"]
README.md CHANGED
@@ -1,10 +1,85 @@
1
  ---
2
- title: Segmentation Eval Dashboard V2
3
- emoji: 👀
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Segmentation Eval Dashboard
3
+ emoji: 📊
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Segmentation Evaluation Metrics Dashboard
12
+
13
+ Streamlit dashboard for tracking segmentation evaluation metrics stored in Hugging Face **dataset cards** under a Markdown section titled exactly:
14
+
15
+ `### Performance Metrics`
16
+
17
+ The app discovers dataset repos (typically with `eval` in the repo id), parses the metrics table, and builds a historical view from commit revisions when possible.
18
+
19
+ ## Features
20
+
21
+ - Dataset discovery from Hugging Face Hub (`huggingface_hub` API)
22
+ - Allowlist/prefix-based repo filtering via environment variables
23
+ - Robust Markdown section/table parsing for `Performance Metrics`
24
+ - Historical trend extraction using dataset commit history
25
+ - Filters by repo, class, and metric
26
+ - Trend chart with optional standard-deviation bands
27
+ - Latest snapshot table
28
+ - Repo-to-repo comparison chart on latest runs
29
+ - Optional per-class small multiples
30
+ - CSV export for filtered data
31
+ - Health/status panel (parsed vs failed repos)
32
+ - Auto-refresh and cached Hub calls
33
+
34
+ ## Project Structure
35
+
36
+ - `app.py`: Streamlit entrypoint and UI
37
+ - `config.py`: env-based configuration
38
+ - `hf_client.py`: Hugging Face Hub API access and README retrieval
39
+ - `parsing.py`: Markdown section/table extraction
40
+ - `transforms.py`: data normalization, history shaping, latest snapshots
41
+ - `charts.py`: Altair chart builders
42
+ - `requirements.txt`: Python dependencies
43
+
44
+ ## Required Environment Variables
45
+
46
+ - `HF_TOKEN` (optional, recommended for private repos or higher rate limits)
47
+ - `HF_OWNER` (optional namespace/organization filter, e.g. `my-org`)
48
+
49
+ ## Optional Environment Variables
50
+
51
+ - `EVAL_REPO_ALLOWLIST` (comma-separated dataset ids)
52
+ - `EVAL_REPO_PREFIXES` (comma-separated prefixes, e.g. `my-org/`)
53
+ - `EVAL_REPO_ID_CONTAINS` (default: `eval`)
54
+ - `EVAL_DISCOVERY_ENABLED` (`true`/`false`, default: `true`)
55
+ - `EVAL_MAX_REPOS` (default: `200`)
56
+ - `EVAL_INCLUDE_HISTORY` (`true`/`false`, default: `true`)
57
+ - `EVAL_MAX_COMMITS_PER_REPO` (default: `20`)
58
+ - `EVAL_METRICS_SECTION_HEADING` (default: `Performance Metrics`)
59
+ - `EVAL_CACHE_TTL_SECONDS` (default: `600`)
60
+ - `EVAL_AUTO_REFRESH_SECONDS` (default: `300`)
61
+ - `EVAL_MAX_LOAD_SECONDS` (default: `90`)
62
+
63
+ ## Local Run
64
+
65
+ ```bash
66
+ pip install -r requirements.txt
67
+ streamlit run app.py
68
+ ```
69
+
70
+ ## Deploy to Hugging Face Space
71
+
72
+ 1. Create a new **Space** with SDK = **Streamlit**.
73
+ 2. Copy all files in this folder to the Space repository root.
74
+ 3. In Space settings, set secrets/variables as needed:
75
+ - `HF_TOKEN` (as a secret)
76
+ - any optional `EVAL_*` vars for discovery behavior
77
+ 4. Push to the Space.
78
+ 5. The app will start from `app.py`.
79
+
80
+ ## Notes / Limitations
81
+
82
+ - History quality depends on README/table availability across commits.
83
+ - If commit history is unavailable or inaccessible, the app still supports latest snapshot parsing.
84
+ - Table parsing is tolerant to minor Markdown inconsistencies, but severely malformed tables are skipped.
85
+ - Missing numeric values (`nan`, `NA`, empty) are treated as `NaN` and handled safely in charts/tables.
app.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+ import streamlit as st
10
+
11
+ from charts import (
12
+ build_class_small_multiples,
13
+ build_latest_comparison_chart,
14
+ build_trend_chart,
15
+ )
16
+ from config import AppConfig, load_config
17
+ from hf_client import HubClient, RepoRevision
18
+ from parsing import extract_performance_metrics_table
19
+ from transforms import (
20
+ add_latest_flags,
21
+ build_latest_snapshot_table,
22
+ filter_history,
23
+ normalize_metrics_dataframe,
24
+ table_to_long_dataframe,
25
+ )
26
+
27
+ try:
28
+ from streamlit_autorefresh import st_autorefresh
29
+ except Exception: # pragma: no cover
30
+ def st_autorefresh(*args: Any, **kwargs: Any) -> None:
31
+ return None
32
+
33
+
34
+ CACHE_TTL_SECONDS = int(os.getenv("EVAL_CACHE_TTL_SECONDS", "600"))
35
+ LOG_LEVEL = os.getenv("EVAL_LOG_LEVEL", "INFO").upper()
36
+ logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO))
37
+ logger = logging.getLogger("segmentation_eval_dashboard")
38
+
39
+
40
+ st.set_page_config(
41
+ page_title="Segmentation Evaluation Dashboard",
42
+ layout="wide",
43
+ )
44
+
45
+
46
+ @st.cache_data(ttl=CACHE_TTL_SECONDS, show_spinner="Loading metrics from Hugging Face Hub...")
47
+ def collect_dashboard_data(config: AppConfig) -> tuple[pd.DataFrame, pd.DataFrame, list[str]]:
48
+ client = HubClient(config)
49
+ repos = client.discover_dataset_repos()
50
+ logger.info("Discovered %d candidate repos", len(repos))
51
+ start_time = time.monotonic()
52
+
53
+ history_frames: list[pd.DataFrame] = []
54
+ status_rows: list[dict[str, Any]] = []
55
+
56
+ for repo_id in repos:
57
+ elapsed = time.monotonic() - start_time
58
+ if elapsed >= config.max_load_seconds:
59
+ status_rows.append(
60
+ {
61
+ "repo_id": "__load_guard__",
62
+ "status": "partial",
63
+ "revisions_checked": 0,
64
+ "revisions_parsed": 0,
65
+ "points": 0,
66
+ "latest_timestamp": pd.NaT,
67
+ "message": f"Stopped early after {config.max_load_seconds}s to keep app responsive",
68
+ }
69
+ )
70
+ logger.warning("Load guard triggered after %.1fs", elapsed)
71
+ break
72
+
73
+ revisions_checked = 0
74
+ revisions_parsed = 0
75
+ points = 0
76
+ message = ""
77
+ commit_error_message = ""
78
+
79
+ try:
80
+ commit_limit = config.max_commits_per_repo if config.include_history else 1
81
+ revisions = client.list_dataset_commits(repo_id=repo_id, max_commits=max(1, commit_limit))
82
+ except Exception as exc: # pragma: no cover
83
+ revisions = [RepoRevision(repo_id=repo_id, revision="main", committed_at=None)]
84
+ commit_error_message = f"Commit listing failed ({type(exc).__name__}); fallback to main"
85
+ logger.warning("Repo %s: %s", repo_id, commit_error_message)
86
+
87
+ repo_frames: list[pd.DataFrame] = []
88
+ seen_revisions: set[str] = set()
89
+ for revision_info in revisions:
90
+ revision = revision_info.revision
91
+ if revision in seen_revisions:
92
+ continue
93
+
94
+ revisions_checked += 1
95
+ seen_revisions.add(revision)
96
+
97
+ readme = client.fetch_dataset_readme(repo_id=repo_id, revision=revision)
98
+ if not readme:
99
+ continue
100
+
101
+ metrics_table = extract_performance_metrics_table(
102
+ markdown=readme,
103
+ heading=config.metrics_section_heading,
104
+ )
105
+ if metrics_table is None or metrics_table.empty:
106
+ continue
107
+
108
+ normalized = normalize_metrics_dataframe(metrics_table)
109
+ if normalized.empty:
110
+ continue
111
+
112
+ long_df = table_to_long_dataframe(
113
+ dataframe=normalized,
114
+ repo_id=repo_id,
115
+ revision=revision,
116
+ committed_at=revision_info.committed_at,
117
+ metrics=config.metrics,
118
+ )
119
+ if long_df.empty:
120
+ continue
121
+
122
+ revisions_parsed += 1
123
+ points += len(long_df)
124
+ repo_frames.append(long_df)
125
+
126
+ if repo_frames:
127
+ repo_history = pd.concat(repo_frames, ignore_index=True)
128
+ history_frames.append(repo_history)
129
+ latest_timestamp = repo_history["timestamp"].max()
130
+ status = "ok"
131
+ message = f"Parsed {revisions_parsed}/{revisions_checked} revisions"
132
+ if commit_error_message:
133
+ message = f"{message}. {commit_error_message}"
134
+ else:
135
+ latest_timestamp = pd.NaT
136
+ status = "error" if commit_error_message else "no_metrics"
137
+ message = commit_error_message or "No valid Performance Metrics table found"
138
+
139
+ status_rows.append(
140
+ {
141
+ "repo_id": repo_id,
142
+ "status": status,
143
+ "revisions_checked": revisions_checked,
144
+ "revisions_parsed": revisions_parsed,
145
+ "points": points,
146
+ "latest_timestamp": latest_timestamp,
147
+ "message": message,
148
+ }
149
+ )
150
+ logger.info(
151
+ "Repo %s: status=%s revisions_checked=%d revisions_parsed=%d points=%d",
152
+ repo_id,
153
+ status,
154
+ revisions_checked,
155
+ revisions_parsed,
156
+ points,
157
+ )
158
+
159
+ if history_frames:
160
+ history_df = pd.concat(history_frames, ignore_index=True)
161
+ history_df = history_df.sort_values(["timestamp", "repo_id", "revision", "class", "metric"]) # type: ignore[arg-type]
162
+ history_df = add_latest_flags(history_df)
163
+ else:
164
+ history_df = pd.DataFrame(
165
+ columns=["repo_id", "revision", "timestamp", "class", "metric", "value", "std", "is_latest"]
166
+ )
167
+
168
+ status_df = pd.DataFrame(status_rows)
169
+ if not status_df.empty and "latest_timestamp" in status_df.columns:
170
+ status_df["latest_timestamp"] = pd.to_datetime(status_df["latest_timestamp"], utc=True, errors="coerce")
171
+
172
+ return history_df, status_df, repos
173
+
174
+
175
+ def main() -> None:
176
+ config = load_config()
177
+
178
+ if config.auto_refresh_seconds > 0:
179
+ st_autorefresh(interval=config.auto_refresh_seconds * 1000, key="dashboard-refresh")
180
+
181
+ st.title("Segmentation Evaluation Metrics Dashboard")
182
+ st.caption("Source: Hugging Face dataset cards (`### Performance Metrics`).")
183
+
184
+ with st.sidebar:
185
+ st.header("Controls")
186
+ if st.button("Refresh now", use_container_width=True):
187
+ st.cache_data.clear()
188
+ st.rerun()
189
+
190
+ st.caption(
191
+ "Filtering/discovery is configured by env vars: "
192
+ "`HF_OWNER`, `EVAL_REPO_ALLOWLIST`, `EVAL_REPO_PREFIXES`, `EVAL_REPO_ID_CONTAINS`."
193
+ )
194
+ show_error_bands = st.checkbox("Show std error bands", value=True)
195
+ show_small_multiples = st.checkbox("Show class small multiples", value=False)
196
+
197
+ history_df, status_df, discovered_repos = collect_dashboard_data(config)
198
+ logger.info(
199
+ "UI load complete: discovered=%d parsed_rows=%d status_rows=%d",
200
+ len(discovered_repos),
201
+ len(history_df),
202
+ len(status_df),
203
+ )
204
+
205
+ if not discovered_repos:
206
+ st.warning("No dataset repositories were discovered. Check owner/prefix/allowlist environment variables.")
207
+ st.stop()
208
+
209
+ available_repos = sorted(history_df["repo_id"].dropna().unique().tolist()) if not history_df.empty else discovered_repos
210
+ available_classes = sorted(history_df["class"].dropna().unique().tolist()) if not history_df.empty else []
211
+ available_metrics = sorted(history_df["metric"].dropna().unique().tolist()) if not history_df.empty else list(config.metrics)
212
+
213
+ with st.sidebar:
214
+ selected_repos = st.multiselect("Repos", options=available_repos, default=available_repos)
215
+ selected_classes = st.multiselect("Classes", options=available_classes, default=available_classes)
216
+ default_metrics = [metric for metric in config.metrics if metric in available_metrics] or available_metrics
217
+ selected_metrics = st.multiselect("Metrics", options=available_metrics, default=default_metrics)
218
+
219
+ filtered_history = filter_history(
220
+ history_df=history_df,
221
+ repos=selected_repos,
222
+ classes=selected_classes,
223
+ metrics=selected_metrics,
224
+ )
225
+
226
+ latest_filtered = filtered_history[filtered_history["is_latest"]].copy()
227
+
228
+ success_repos = int((status_df["status"] == "ok").sum()) if not status_df.empty else 0
229
+ latest_runs = latest_filtered[["repo_id", "revision"]].drop_duplicates().shape[0] if not latest_filtered.empty else 0
230
+ latest_f1 = latest_filtered[latest_filtered["metric"] == "F1"]["value"].mean() if not latest_filtered.empty else float("nan")
231
+ latest_timestamp = filtered_history["timestamp"].max() if not filtered_history.empty else pd.NaT
232
+
233
+ col1, col2, col3, col4 = st.columns(4)
234
+ col1.metric("Discovered repos", len(discovered_repos))
235
+ col2.metric("Repos parsed", success_repos)
236
+ col3.metric("Latest runs in view", latest_runs)
237
+ col4.metric("Latest mean F1", f"{latest_f1:.3f}" if pd.notna(latest_f1) else "n/a")
238
+
239
+ if pd.notna(latest_timestamp):
240
+ st.caption(f"Most recent datapoint: {pd.to_datetime(latest_timestamp).strftime('%Y-%m-%d %H:%M:%S %Z')}")
241
+
242
+ st.subheader("Trend Over Time")
243
+ trend_chart = build_trend_chart(filtered_history, show_error_bands=show_error_bands)
244
+ if trend_chart is None:
245
+ st.info("No trend data available for the active filters.")
246
+ else:
247
+ st.altair_chart(trend_chart, use_container_width=True)
248
+
249
+ st.subheader("Latest Snapshot")
250
+ snapshot_table = build_latest_snapshot_table(filtered_history)
251
+ if snapshot_table.empty:
252
+ st.info("No latest snapshot rows for current filters.")
253
+ else:
254
+ st.dataframe(snapshot_table, use_container_width=True, hide_index=True)
255
+
256
+ st.subheader("Repo Comparison (Latest)")
257
+ comparison_metric_options = selected_metrics or available_metrics
258
+ comparison_metric = None
259
+ if comparison_metric_options:
260
+ comparison_metric = st.selectbox(
261
+ "Metric for repo comparison",
262
+ options=comparison_metric_options,
263
+ index=0,
264
+ )
265
+
266
+ if comparison_metric is not None:
267
+ comparison_chart = build_latest_comparison_chart(filtered_history, metric=comparison_metric)
268
+ if comparison_chart is None:
269
+ st.info("No latest comparison data available for this metric.")
270
+ else:
271
+ st.altair_chart(comparison_chart, use_container_width=True)
272
+
273
+ if show_small_multiples and comparison_metric:
274
+ st.subheader("Per-Class Small Multiples")
275
+ small_multiples_chart = build_class_small_multiples(filtered_history, metric=comparison_metric)
276
+ if small_multiples_chart is None:
277
+ st.info("No small-multiple data available for this metric.")
278
+ else:
279
+ st.altair_chart(small_multiples_chart, use_container_width=True)
280
+
281
+ st.subheader("Export")
282
+ csv_bytes = filtered_history.to_csv(index=False).encode("utf-8")
283
+ st.download_button(
284
+ "Export filtered history as CSV",
285
+ data=csv_bytes,
286
+ file_name="segmentation_metrics_history.csv",
287
+ mime="text/csv",
288
+ use_container_width=False,
289
+ )
290
+
291
+ with st.expander("Health / Status", expanded=False):
292
+ if status_df.empty:
293
+ st.info("No status rows to display.")
294
+ else:
295
+ st.dataframe(status_df.sort_values(["status", "repo_id"]), use_container_width=True, hide_index=True)
296
+
297
+ with st.expander("Raw Parsed Data", expanded=False):
298
+ if filtered_history.empty:
299
+ st.info("No parsed rows for current filters.")
300
+ else:
301
+ st.dataframe(
302
+ filtered_history.sort_values(["timestamp", "repo_id", "class", "metric"], ascending=[False, True, True, True]),
303
+ use_container_width=True,
304
+ hide_index=True,
305
+ )
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()
charts.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import altair as alt
4
+ import pandas as pd
5
+
6
+
7
+ def build_trend_chart(dataframe: pd.DataFrame, show_error_bands: bool = False) -> alt.Chart | None:
8
+ if dataframe.empty:
9
+ return None
10
+
11
+ chart_data = dataframe.copy()
12
+ chart_data = chart_data.dropna(subset=["timestamp", "value"])
13
+ if chart_data.empty:
14
+ return None
15
+
16
+ chart_data["repo_class"] = chart_data["repo_id"] + " | " + chart_data["class"]
17
+
18
+ base = alt.Chart(chart_data).encode(
19
+ x=alt.X("timestamp:T", title="Time"),
20
+ y=alt.Y("value:Q", title="Metric value"),
21
+ color=alt.Color("repo_id:N", title="Repo"),
22
+ strokeDash=alt.StrokeDash("class:N", title="Class"),
23
+ tooltip=[
24
+ alt.Tooltip("timestamp:T", title="Timestamp"),
25
+ alt.Tooltip("repo_id:N", title="Repo"),
26
+ alt.Tooltip("revision:N", title="Revision"),
27
+ alt.Tooltip("class:N", title="Class"),
28
+ alt.Tooltip("metric:N", title="Metric"),
29
+ alt.Tooltip("value:Q", title="Value", format=".4f"),
30
+ alt.Tooltip("std:Q", title="Std", format=".4f"),
31
+ ],
32
+ )
33
+
34
+ line = base.mark_line(point=True)
35
+
36
+ chart: alt.Chart | alt.LayerChart = line
37
+ band_data = chart_data.dropna(subset=["std"]).copy()
38
+ if show_error_bands and not band_data.empty:
39
+ band_data["lower"] = band_data["value"] - band_data["std"]
40
+ band_data["upper"] = band_data["value"] + band_data["std"]
41
+
42
+ band = alt.Chart(band_data).mark_area(opacity=0.15).encode(
43
+ x=alt.X("timestamp:T", title="Time"),
44
+ y=alt.Y("lower:Q", title="Metric value"),
45
+ y2="upper:Q",
46
+ color=alt.Color("repo_id:N", title="Repo"),
47
+ detail="repo_class:N",
48
+ )
49
+ chart = band + line
50
+
51
+ metric_count = chart_data["metric"].nunique()
52
+ if metric_count > 1:
53
+ return chart.facet(row=alt.Row("metric:N", title=None)).resolve_scale(y="independent")
54
+
55
+ return chart
56
+
57
+
58
+ def build_latest_comparison_chart(dataframe: pd.DataFrame, metric: str) -> alt.Chart | None:
59
+ if dataframe.empty:
60
+ return None
61
+
62
+ latest = dataframe[dataframe["is_latest"] & (dataframe["metric"] == metric)].dropna(subset=["value"])
63
+ if latest.empty:
64
+ return None
65
+
66
+ return (
67
+ alt.Chart(latest)
68
+ .mark_bar()
69
+ .encode(
70
+ x=alt.X("repo_id:N", title="Repo", sort="-y"),
71
+ y=alt.Y("value:Q", title=f"Latest {metric}"),
72
+ color=alt.Color("class:N", title="Class"),
73
+ tooltip=[
74
+ alt.Tooltip("repo_id:N", title="Repo"),
75
+ alt.Tooltip("revision:N", title="Revision"),
76
+ alt.Tooltip("class:N", title="Class"),
77
+ alt.Tooltip("value:Q", title="Value", format=".4f"),
78
+ alt.Tooltip("std:Q", title="Std", format=".4f"),
79
+ ],
80
+ )
81
+ )
82
+
83
+
84
+ def build_class_small_multiples(dataframe: pd.DataFrame, metric: str) -> alt.Chart | None:
85
+ if dataframe.empty:
86
+ return None
87
+
88
+ plot_df = dataframe[dataframe["metric"] == metric].dropna(subset=["value"])
89
+ if plot_df.empty:
90
+ return None
91
+
92
+ chart = (
93
+ alt.Chart(plot_df)
94
+ .mark_line(point=True)
95
+ .encode(
96
+ x=alt.X("timestamp:T", title="Time"),
97
+ y=alt.Y("value:Q", title=metric),
98
+ color=alt.Color("repo_id:N", title="Repo"),
99
+ tooltip=[
100
+ alt.Tooltip("timestamp:T", title="Timestamp"),
101
+ alt.Tooltip("repo_id:N", title="Repo"),
102
+ alt.Tooltip("revision:N", title="Revision"),
103
+ alt.Tooltip("class:N", title="Class"),
104
+ alt.Tooltip("value:Q", title="Value", format=".4f"),
105
+ ],
106
+ )
107
+ )
108
+
109
+ return chart.facet(column=alt.Column("class:N", title=None, sort="ascending"))
config.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from typing import Tuple
6
+
7
+ DEFAULT_METRICS: Tuple[str, ...] = (
8
+ "F1",
9
+ "Precision",
10
+ "Recall",
11
+ "Mean_IoU",
12
+ "Mean_TP_IoU",
13
+ )
14
+
15
+
16
+ def _parse_bool(value: str | None, default: bool) -> bool:
17
+ if value is None:
18
+ return default
19
+ normalized = value.strip().lower()
20
+ if normalized in {"1", "true", "yes", "y", "on"}:
21
+ return True
22
+ if normalized in {"0", "false", "no", "n", "off"}:
23
+ return False
24
+ return default
25
+
26
+
27
+ def _parse_csv(value: str | None) -> tuple[str, ...]:
28
+ if not value:
29
+ return tuple()
30
+ items = [item.strip() for item in value.split(",")]
31
+ return tuple(item for item in items if item)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AppConfig:
36
+ hf_token: str | None
37
+ hf_owner: str | None
38
+ repo_allowlist: tuple[str, ...]
39
+ repo_prefixes: tuple[str, ...]
40
+ repo_id_contains: str
41
+ discovery_enabled: bool
42
+ max_repos: int
43
+ include_history: bool
44
+ max_commits_per_repo: int
45
+ metrics_section_heading: str
46
+ cache_ttl_seconds: int
47
+ auto_refresh_seconds: int
48
+ max_load_seconds: int
49
+ metrics: tuple[str, ...]
50
+
51
+
52
+
53
+ def load_config() -> AppConfig:
54
+ return AppConfig(
55
+ hf_token=os.getenv("HF_TOKEN") or None,
56
+ hf_owner=os.getenv("HF_OWNER") or None,
57
+ repo_allowlist=_parse_csv(os.getenv("EVAL_REPO_ALLOWLIST")),
58
+ repo_prefixes=_parse_csv(os.getenv("EVAL_REPO_PREFIXES")),
59
+ repo_id_contains=os.getenv("EVAL_REPO_ID_CONTAINS", "eval").strip(),
60
+ discovery_enabled=_parse_bool(os.getenv("EVAL_DISCOVERY_ENABLED"), True),
61
+ max_repos=int(os.getenv("EVAL_MAX_REPOS", "200")),
62
+ include_history=_parse_bool(os.getenv("EVAL_INCLUDE_HISTORY"), True),
63
+ max_commits_per_repo=int(os.getenv("EVAL_MAX_COMMITS_PER_REPO", "20")),
64
+ metrics_section_heading=os.getenv("EVAL_METRICS_SECTION_HEADING", "Performance Metrics").strip(),
65
+ cache_ttl_seconds=int(os.getenv("EVAL_CACHE_TTL_SECONDS", "600")),
66
+ auto_refresh_seconds=int(os.getenv("EVAL_AUTO_REFRESH_SECONDS", "300")),
67
+ max_load_seconds=int(os.getenv("EVAL_MAX_LOAD_SECONDS", "90")),
68
+ metrics=DEFAULT_METRICS,
69
+ )
hf_client.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+ from huggingface_hub import HfApi, hf_hub_download
10
+ from huggingface_hub.utils import EntryNotFoundError, HfHubHTTPError
11
+
12
+ from config import AppConfig
13
+
14
+
15
+ README_CANDIDATES = ("README.md", "readme.md")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RepoRevision:
20
+ repo_id: str
21
+ revision: str
22
+ committed_at: datetime | None
23
+
24
+
25
+ class HubClient:
26
+ def __init__(self, config: AppConfig):
27
+ self.config = config
28
+ self.api = HfApi(token=config.hf_token)
29
+
30
+ def discover_dataset_repos(self) -> list[str]:
31
+ repos = set(self.config.repo_allowlist)
32
+
33
+ if self.config.discovery_enabled:
34
+ try:
35
+ datasets = self.api.list_datasets(
36
+ author=self.config.hf_owner,
37
+ search=self.config.repo_id_contains or None,
38
+ limit=max(self.config.max_repos * 3, self.config.max_repos),
39
+ full=False,
40
+ )
41
+ for dataset in datasets:
42
+ repo_id = getattr(dataset, "id", None)
43
+ if not repo_id:
44
+ continue
45
+ if self._repo_matches_filters(repo_id):
46
+ repos.add(repo_id)
47
+ if len(repos) >= self.config.max_repos:
48
+ break
49
+ except HfHubHTTPError:
50
+ pass
51
+
52
+ filtered = [repo for repo in repos if self._repo_matches_filters(repo)]
53
+ return sorted(filtered)[: self.config.max_repos]
54
+
55
+ def list_dataset_commits(self, repo_id: str, max_commits: int) -> list[RepoRevision]:
56
+ revisions: list[RepoRevision] = []
57
+ try:
58
+ commits = self.api.list_repo_commits(repo_id=repo_id, repo_type="dataset")
59
+ for commit in commits[:max_commits]:
60
+ revision = getattr(commit, "commit_id", None) or getattr(commit, "oid", None)
61
+ if not revision:
62
+ continue
63
+ committed_at = _coerce_datetime(getattr(commit, "created_at", None) or getattr(commit, "date", None))
64
+ revisions.append(
65
+ RepoRevision(
66
+ repo_id=repo_id,
67
+ revision=revision,
68
+ committed_at=committed_at,
69
+ )
70
+ )
71
+ except HfHubHTTPError:
72
+ revisions = []
73
+
74
+ if revisions:
75
+ return revisions
76
+
77
+ try:
78
+ info = self.api.dataset_info(repo_id=repo_id)
79
+ fallback_revision = getattr(info, "sha", None) or "main"
80
+ fallback_timestamp = _coerce_datetime(getattr(info, "lastModified", None))
81
+ except HfHubHTTPError:
82
+ fallback_revision = "main"
83
+ fallback_timestamp = None
84
+
85
+ return [RepoRevision(repo_id=repo_id, revision=fallback_revision, committed_at=fallback_timestamp)]
86
+
87
+ def fetch_dataset_readme(self, repo_id: str, revision: str | None = None) -> str | None:
88
+ for filename in README_CANDIDATES:
89
+ try:
90
+ path = hf_hub_download(
91
+ repo_id=repo_id,
92
+ filename=filename,
93
+ repo_type="dataset",
94
+ revision=revision,
95
+ token=self.config.hf_token,
96
+ )
97
+ return Path(path).read_text(encoding="utf-8", errors="replace")
98
+ except EntryNotFoundError:
99
+ continue
100
+ except HfHubHTTPError:
101
+ continue
102
+ return None
103
+
104
+ def _repo_matches_filters(self, repo_id: str) -> bool:
105
+ lowered_repo_id = repo_id.lower()
106
+
107
+ if self.config.repo_id_contains and self.config.repo_id_contains.lower() not in lowered_repo_id:
108
+ return False
109
+
110
+ if self.config.repo_prefixes and not any(repo_id.startswith(prefix) for prefix in self.config.repo_prefixes):
111
+ return False
112
+
113
+ return True
114
+
115
+
116
+ def _coerce_datetime(value: Any) -> datetime | None:
117
+ if value is None:
118
+ return None
119
+
120
+ timestamp = pd.to_datetime(value, errors="coerce", utc=True)
121
+ if pd.isna(timestamp):
122
+ return None
123
+ return timestamp.to_pydatetime()
parsing.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Iterable
5
+
6
+ import pandas as pd
7
+
8
+
9
+ HEADING_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*$")
10
+ TABLE_DIVIDER_CELL_RE = re.compile(r"^:?-{3,}:?$")
11
+
12
+
13
+ def extract_markdown_section(markdown: str, heading: str) -> str | None:
14
+ lines = markdown.splitlines()
15
+ heading_normalized = heading.strip().lower()
16
+
17
+ start_index: int | None = None
18
+ heading_level: int | None = None
19
+
20
+ for index, line in enumerate(lines):
21
+ match = HEADING_RE.match(line)
22
+ if not match:
23
+ continue
24
+ title = match.group(2).strip().lower()
25
+ if title == heading_normalized:
26
+ start_index = index + 1
27
+ heading_level = len(match.group(1))
28
+ break
29
+
30
+ if start_index is None or heading_level is None:
31
+ return None
32
+
33
+ end_index = len(lines)
34
+ for index in range(start_index, len(lines)):
35
+ match = HEADING_RE.match(lines[index])
36
+ if not match:
37
+ continue
38
+ next_heading_level = len(match.group(1))
39
+ if next_heading_level <= heading_level:
40
+ end_index = index
41
+ break
42
+
43
+ section = "\n".join(lines[start_index:end_index]).strip()
44
+ return section or None
45
+
46
+
47
+ def extract_performance_metrics_table(markdown: str, heading: str = "Performance Metrics") -> pd.DataFrame | None:
48
+ section = extract_markdown_section(markdown, heading)
49
+ if not section:
50
+ return None
51
+
52
+ blocks = _extract_table_blocks(section)
53
+ for block in blocks:
54
+ table = _parse_markdown_table_block(block)
55
+ if table is None or table.empty:
56
+ continue
57
+
58
+ normalized_columns = {str(col).strip().lower() for col in table.columns}
59
+ if "class" in normalized_columns:
60
+ return table
61
+
62
+ if blocks:
63
+ fallback = _parse_markdown_table_block(blocks[0])
64
+ if fallback is not None and not fallback.empty:
65
+ return fallback
66
+
67
+ return None
68
+
69
+
70
+ def _extract_table_blocks(text: str) -> list[list[str]]:
71
+ lines = text.splitlines()
72
+ blocks: list[list[str]] = []
73
+ current_block: list[str] = []
74
+
75
+ for line in lines:
76
+ if "|" in line:
77
+ current_block.append(line)
78
+ continue
79
+
80
+ if len(current_block) >= 2:
81
+ blocks.append(current_block)
82
+ current_block = []
83
+
84
+ if len(current_block) >= 2:
85
+ blocks.append(current_block)
86
+
87
+ return blocks
88
+
89
+
90
+ def _parse_markdown_table_block(lines: Iterable[str]) -> pd.DataFrame | None:
91
+ rows = [_split_markdown_row(line) for line in lines if line.strip()]
92
+ if len(rows) < 2:
93
+ return None
94
+
95
+ header_index = None
96
+ for index in range(len(rows) - 1):
97
+ if _is_divider_row(rows[index + 1]):
98
+ header_index = index
99
+ break
100
+
101
+ if header_index is None:
102
+ return None
103
+
104
+ header = [cell.strip() for cell in rows[header_index]]
105
+ if not header or len(header) < 2:
106
+ return None
107
+
108
+ data_rows = rows[header_index + 2 :]
109
+ normalized_rows: list[list[str]] = []
110
+
111
+ for row in data_rows:
112
+ if _is_divider_row(row):
113
+ continue
114
+
115
+ padded = row[: len(header)] + [""] * max(0, len(header) - len(row))
116
+ if any(cell.strip() for cell in padded):
117
+ normalized_rows.append(padded)
118
+
119
+ if not normalized_rows:
120
+ return None
121
+
122
+ dataframe = pd.DataFrame(normalized_rows, columns=header)
123
+ dataframe.columns = [str(column).strip() for column in dataframe.columns]
124
+ return dataframe
125
+
126
+
127
+ def _split_markdown_row(line: str) -> list[str]:
128
+ raw = line.strip().strip("|")
129
+ return [cell.strip() for cell in raw.split("|")]
130
+
131
+
132
+ def _is_divider_row(row: Iterable[str]) -> bool:
133
+ cells = [cell.strip() for cell in row]
134
+ if not cells:
135
+ return False
136
+ return all(TABLE_DIVIDER_CELL_RE.match(cell) for cell in cells if cell)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ streamlit>=1.36.0
2
+ pandas>=2.2.0
3
+ numpy>=1.26.0
4
+ altair>=5.3.0
5
+ huggingface_hub>=0.24.0
6
+ streamlit-autorefresh>=1.0.1
transforms.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ import re
5
+ from typing import Iterable
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+
11
+ _CANONICAL_METRIC_MAP = {
12
+ "precision": "Precision",
13
+ "recall": "Recall",
14
+ "f1": "F1",
15
+ "meaniou": "Mean_IoU",
16
+ "meantpiou": "Mean_TP_IoU",
17
+ }
18
+
19
+
20
+ def normalize_metrics_dataframe(dataframe: pd.DataFrame) -> pd.DataFrame:
21
+ if dataframe.empty:
22
+ return pd.DataFrame()
23
+
24
+ rename_map: dict[str, str] = {}
25
+ class_column: str | None = None
26
+
27
+ for column in dataframe.columns:
28
+ normalized = _normalize_key(column)
29
+ if normalized == "class":
30
+ class_column = str(column)
31
+ rename_map[str(column)] = "Class"
32
+ continue
33
+
34
+ if normalized.endswith("std"):
35
+ metric_key = normalized[: -len("std")]
36
+ metric_name = _CANONICAL_METRIC_MAP.get(metric_key)
37
+ if metric_name:
38
+ rename_map[str(column)] = f"{metric_name}_std"
39
+ continue
40
+
41
+ metric_name = _CANONICAL_METRIC_MAP.get(normalized)
42
+ if metric_name:
43
+ rename_map[str(column)] = metric_name
44
+
45
+ if class_column is None:
46
+ return pd.DataFrame()
47
+
48
+ cleaned = dataframe.rename(columns=rename_map).copy()
49
+ cleaned["Class"] = cleaned["Class"].astype(str).str.strip()
50
+ cleaned = cleaned[cleaned["Class"] != ""]
51
+
52
+ for column in cleaned.columns:
53
+ if column == "Class":
54
+ continue
55
+ cleaned[column] = cleaned[column].apply(_parse_float)
56
+
57
+ return cleaned.reset_index(drop=True)
58
+
59
+
60
+ def table_to_long_dataframe(
61
+ dataframe: pd.DataFrame,
62
+ repo_id: str,
63
+ revision: str,
64
+ committed_at: datetime | None,
65
+ metrics: Iterable[str],
66
+ ) -> pd.DataFrame:
67
+ if dataframe.empty:
68
+ return pd.DataFrame()
69
+
70
+ timestamp = pd.to_datetime(committed_at or datetime.now(timezone.utc), utc=True)
71
+
72
+ frames: list[pd.DataFrame] = []
73
+ for metric in metrics:
74
+ if metric not in dataframe.columns:
75
+ continue
76
+
77
+ metric_frame = pd.DataFrame(
78
+ {
79
+ "repo_id": repo_id,
80
+ "revision": revision,
81
+ "timestamp": timestamp,
82
+ "class": dataframe["Class"].astype(str),
83
+ "metric": metric,
84
+ "value": dataframe[metric],
85
+ "std": dataframe.get(f"{metric}_std", np.nan),
86
+ }
87
+ )
88
+ frames.append(metric_frame)
89
+
90
+ if not frames:
91
+ return pd.DataFrame()
92
+
93
+ long_df = pd.concat(frames, ignore_index=True)
94
+ long_df["value"] = pd.to_numeric(long_df["value"], errors="coerce")
95
+ long_df["std"] = pd.to_numeric(long_df["std"], errors="coerce")
96
+ long_df["timestamp"] = pd.to_datetime(long_df["timestamp"], utc=True, errors="coerce")
97
+ long_df = long_df.dropna(subset=["timestamp"])
98
+ return long_df
99
+
100
+
101
+ def add_latest_flags(history_df: pd.DataFrame) -> pd.DataFrame:
102
+ if history_df.empty:
103
+ result = history_df.copy()
104
+ result["is_latest"] = False
105
+ return result
106
+
107
+ unique_runs = history_df[["repo_id", "revision", "timestamp"]].drop_duplicates()
108
+ latest_runs = (
109
+ unique_runs.sort_values(["repo_id", "timestamp", "revision"])
110
+ .groupby("repo_id", as_index=False)
111
+ .tail(1)
112
+ .assign(is_latest=True)
113
+ )
114
+
115
+ merged = history_df.merge(
116
+ latest_runs,
117
+ on=["repo_id", "revision", "timestamp"],
118
+ how="left",
119
+ )
120
+ merged["is_latest"] = merged["is_latest"].fillna(False)
121
+ return merged
122
+
123
+
124
+ def filter_history(
125
+ history_df: pd.DataFrame,
126
+ repos: Iterable[str],
127
+ classes: Iterable[str],
128
+ metrics: Iterable[str],
129
+ ) -> pd.DataFrame:
130
+ if history_df.empty:
131
+ return history_df
132
+
133
+ filtered = history_df.copy()
134
+ repos = list(repos)
135
+ classes = list(classes)
136
+ metrics = list(metrics)
137
+
138
+ if repos:
139
+ filtered = filtered[filtered["repo_id"].isin(repos)]
140
+ if classes:
141
+ filtered = filtered[filtered["class"].isin(classes)]
142
+ if metrics:
143
+ filtered = filtered[filtered["metric"].isin(metrics)]
144
+
145
+ return filtered
146
+
147
+
148
+ def build_latest_snapshot_table(history_df: pd.DataFrame) -> pd.DataFrame:
149
+ if history_df.empty:
150
+ return pd.DataFrame()
151
+
152
+ latest_df = history_df[history_df["is_latest"]].copy()
153
+ if latest_df.empty:
154
+ return pd.DataFrame()
155
+
156
+ value_pivot = latest_df.pivot_table(
157
+ index=["repo_id", "class", "revision", "timestamp"],
158
+ columns="metric",
159
+ values="value",
160
+ aggfunc="first",
161
+ )
162
+ std_pivot = latest_df.pivot_table(
163
+ index=["repo_id", "class", "revision", "timestamp"],
164
+ columns="metric",
165
+ values="std",
166
+ aggfunc="first",
167
+ )
168
+
169
+ if not std_pivot.empty:
170
+ std_pivot.columns = [f"{column}_std" for column in std_pivot.columns]
171
+
172
+ snapshot = value_pivot.join(std_pivot, how="left")
173
+ snapshot = snapshot.reset_index().sort_values(["repo_id", "class"])
174
+ return snapshot
175
+
176
+
177
+ def _normalize_key(value: str) -> str:
178
+ return re.sub(r"[^a-z0-9]", "", str(value).strip().lower())
179
+
180
+
181
+ def _parse_float(value: object) -> float:
182
+ if value is None:
183
+ return np.nan
184
+
185
+ text = str(value).strip()
186
+ if text == "":
187
+ return np.nan
188
+
189
+ lowered = text.lower()
190
+ if lowered in {"nan", "na", "n/a", "none", "null", "-"}:
191
+ return np.nan
192
+
193
+ cleaned = text.replace(",", "")
194
+ try:
195
+ return float(cleaned)
196
+ except ValueError:
197
+ return np.nan