Initial deploy v2
Browse files- .streamlit/config.toml +8 -0
- Dockerfile +32 -0
- README.md +78 -3
- app.py +309 -0
- charts.py +109 -0
- config.py +69 -0
- hf_client.py +123 -0
- parsing.py +136 -0
- requirements.txt +6 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|