from __future__ import annotations from datetime import datetime, timezone import re from typing import Iterable import numpy as np import pandas as pd _CANONICAL_METRIC_MAP = { "precision": "Precision", "recall": "Recall", "f1": "F1", "meaniou": "Mean_IoU", "meantpiou": "Mean_TP_IoU", } def normalize_metrics_dataframe(dataframe: pd.DataFrame) -> pd.DataFrame: if dataframe.empty: return pd.DataFrame() rename_map: dict[str, str] = {} class_column: str | None = None for column in dataframe.columns: normalized = _normalize_key(column) if normalized == "class": class_column = str(column) rename_map[str(column)] = "Class" continue if normalized.endswith("std"): metric_key = normalized[: -len("std")] metric_name = _CANONICAL_METRIC_MAP.get(metric_key) if metric_name: rename_map[str(column)] = f"{metric_name}_std" continue metric_name = _CANONICAL_METRIC_MAP.get(normalized) if metric_name: rename_map[str(column)] = metric_name if class_column is None: return pd.DataFrame() cleaned = dataframe.rename(columns=rename_map).copy() cleaned["Class"] = cleaned["Class"].astype(str).str.strip() cleaned = cleaned[cleaned["Class"] != ""] for column in cleaned.columns: if column == "Class": continue cleaned[column] = cleaned[column].apply(_parse_float) return cleaned.reset_index(drop=True) def table_to_long_dataframe( dataframe: pd.DataFrame, repo_id: str, revision: str, committed_at: datetime | None, metrics: Iterable[str], ) -> pd.DataFrame: if dataframe.empty: return pd.DataFrame() timestamp = pd.to_datetime(committed_at or datetime.now(timezone.utc), utc=True) frames: list[pd.DataFrame] = [] for metric in metrics: if metric not in dataframe.columns: continue metric_frame = pd.DataFrame( { "repo_id": repo_id, "revision": revision, "timestamp": timestamp, "class": dataframe["Class"].astype(str), "metric": metric, "value": dataframe[metric], "std": dataframe.get(f"{metric}_std", np.nan), } ) frames.append(metric_frame) if not frames: return pd.DataFrame() long_df = pd.concat(frames, ignore_index=True) long_df["value"] = pd.to_numeric(long_df["value"], errors="coerce") long_df["std"] = pd.to_numeric(long_df["std"], errors="coerce") long_df["timestamp"] = pd.to_datetime(long_df["timestamp"], utc=True, errors="coerce") long_df = long_df.dropna(subset=["timestamp"]) return long_df def add_latest_flags(history_df: pd.DataFrame) -> pd.DataFrame: if history_df.empty: result = history_df.copy() result["is_latest"] = False return result unique_runs = history_df[["repo_id", "revision", "timestamp"]].drop_duplicates() latest_runs = ( unique_runs.sort_values(["repo_id", "timestamp", "revision"]) .groupby("repo_id", as_index=False) .tail(1) .assign(is_latest=True) ) merged = history_df.merge( latest_runs, on=["repo_id", "revision", "timestamp"], how="left", ) merged["is_latest"] = merged["is_latest"].fillna(False) return merged def filter_history( history_df: pd.DataFrame, repos: Iterable[str], classes: Iterable[str], metrics: Iterable[str], ) -> pd.DataFrame: if history_df.empty: return history_df filtered = history_df.copy() repos = list(repos) classes = list(classes) metrics = list(metrics) if repos: filtered = filtered[filtered["repo_id"].isin(repos)] if classes: filtered = filtered[filtered["class"].isin(classes)] if metrics: filtered = filtered[filtered["metric"].isin(metrics)] return filtered def build_latest_snapshot_table(history_df: pd.DataFrame) -> pd.DataFrame: if history_df.empty: return pd.DataFrame() latest_df = history_df[history_df["is_latest"]].copy() if latest_df.empty: return pd.DataFrame() value_pivot = latest_df.pivot_table( index=["repo_id", "class", "revision", "timestamp"], columns="metric", values="value", aggfunc="first", ) std_pivot = latest_df.pivot_table( index=["repo_id", "class", "revision", "timestamp"], columns="metric", values="std", aggfunc="first", ) if not std_pivot.empty: std_pivot.columns = [f"{column}_std" for column in std_pivot.columns] snapshot = value_pivot.join(std_pivot, how="left") snapshot = snapshot.reset_index().sort_values(["repo_id", "class"]) return snapshot def _normalize_key(value: str) -> str: return re.sub(r"[^a-z0-9]", "", str(value).strip().lower()) def _parse_float(value: object) -> float: if value is None: return np.nan text = str(value).strip() if text == "": return np.nan lowered = text.lower() if lowered in {"nan", "na", "n/a", "none", "null", "-"}: return np.nan cleaned = text.replace(",", "") try: return float(cleaned) except ValueError: return np.nan