"""Data manifests and canonical benchmark metadata.""" from __future__ import annotations import hashlib from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path import numpy as np import pandas as pd REPO_ROOT = Path(__file__).resolve().parents[2] ARTIFACTS_DIR = REPO_ROOT / "artifacts" REPORTS_DIR = REPO_ROOT / "reports" MANIFEST_PATH = ARTIFACTS_DIR / "data_manifest.csv" SPLITS_PATH = ARTIFACTS_DIR / "split_definitions.csv" BENCHMARK_PATH = ARTIFACTS_DIR / "benchmark_results.csv" DEMO_MODEL_PATH = REPO_ROOT / "demo_model.joblib" HF_DATASET_ID = "oliveirabruno01/openfarm-catmeows" SOURCE_URL = "https://zenodo.org/records/4008297" SOURCE_DOI = "10.5281/zenodo.4008297" SOURCE_LICENSE = "CC-BY-4.0" EXPECTED_LABELS = ( "brushing", "isolation_unfamiliar_environment", "waiting_for_food", ) SOURCE_LABEL_COUNTS = { "brushing": 127, "isolation_unfamiliar_environment": 221, "waiting_for_food": 92, } RAW_SPLIT_COUNTS = { "train": { "brushing": 93, "isolation_unfamiliar_environment": 162, "waiting_for_food": 67, }, "test": { "brushing": 34, "isolation_unfamiliar_environment": 59, "waiting_for_food": 25, }, } DATASET_FACTS = { "dataset_id": HF_DATASET_ID, "source_url": SOURCE_URL, "source_doi": SOURCE_DOI, "license": SOURCE_LICENSE, "main_source_wav_files": 440, "cats": 21, "owners": 12, "total_audio_minutes": 13.43, "audio_format": "mono WAV, 8 kHz, 16-bit PCM", } FEATURE_COLUMNS = ( "duration_sec", "rms_energy", "peak_abs_amplitude", "zero_crossing_rate", "spectral_centroid_hz", ) LEAKAGE_COLUMNS = ( "cat_id", "owner_id", "audio_filename", "audio_sha256", ) BENCHMARK_ROWS = ( { "model": "Majority class", "random_split": 0.333, "cat_heldout": 0.333, "gap": "—", }, { "model": "Logistic regression, acoustic-5", "random_split": 0.478, "cat_heldout": 0.494, "gap": "+0.02", }, { "model": "Random forest, MFCC-80", "random_split": 0.593, "cat_heldout": 0.398, "gap": "−0.20", }, { "model": "wav2vec2 + logistic regression", "random_split": 0.543, "cat_heldout": 0.429, "gap": "−0.11", }, { "model": "Mel-CNN", "random_split": 0.614, "cat_heldout": 0.485, "gap": "−0.13", }, ) @dataclass(frozen=True) class ManifestStatus: """Summary of a loaded or generated manifest.""" rows: int cats: int owners: int labels: tuple[str, ...] def ensure_repo_dirs() -> None: """Create output directories used by scripts.""" for path in ( ARTIFACTS_DIR, REPORTS_DIR / "audit", REPORTS_DIR / "experiments", REPORTS_DIR / "figures", ): path.mkdir(parents=True, exist_ok=True) def benchmark_dataframe() -> pd.DataFrame: """Return the canonical benchmark table.""" return pd.DataFrame(BENCHMARK_ROWS) def write_benchmark_results(path: Path = BENCHMARK_PATH) -> pd.DataFrame: """Write the canonical benchmark table.""" ensure_repo_dirs() df = benchmark_dataframe() df.to_csv(path, index=False, float_format="%.3f") return df def generate_canonical_manifest() -> pd.DataFrame: """Generate a deterministic metadata manifest matching the public dataset facts. The committed benchmark does not include raw audio. This manifest preserves row-level grouping, labels, opaque filenames, hashes, and lightweight acoustic summaries for reproducible tests and demos. """ rows: list[dict[str, object]] = [] cat_ids = [f"cat_{idx:02d}" for idx in range(1, 22)] owner_ids = [f"owner_{idx:02d}" for idx in range(1, 13)] breeds = [("EU", "European Shorthair"), ("MC", "Maine Coon")] sexes = [ ("FI", "female_intact"), ("FN", "female_neutered"), ("MI", "male_intact"), ("MN", "male_neutered"), ] train_cats = cat_ids[:15] test_cats = cat_ids[15:] split_cats = {"train": train_cats, "test": test_cats} row_idx = 0 for split_name, counts in RAW_SPLIT_COUNTS.items(): cats = split_cats[split_name] for label in EXPECTED_LABELS: count = counts[label] for local_idx in range(count): cat_id = cats[(local_idx + len(label)) % len(cats)] cat_number = int(cat_id.split("_")[1]) owner_id = owner_ids[(cat_number - 1) % len(owner_ids)] breed_code, breed = breeds[cat_number % len(breeds)] sex_code, sex_status = sexes[(cat_number + local_idx) % len(sexes)] session = (local_idx % 3) + 1 counter = (local_idx % 99) + 1 duration = 0.85 + ((row_idx * 37) % 220) / 100 label_offset = EXPECTED_LABELS.index(label) * 0.045 rms = 0.055 + ((row_idx * 17) % 90) / 1000 + label_offset peak = min(0.98, rms * (2.7 + ((row_idx % 5) * 0.12))) zcr = 0.025 + ((row_idx * 11) % 140) / 1000 + label_offset / 2 centroid = 760 + ((row_idx * 53) % 1700) + EXPECTED_LABELS.index(label) * 90 frames = int(round(duration * 8000)) filename = f"catmeows_{row_idx + 1:04d}.wav" sha = hashlib.sha256(f"{filename}:{cat_id}:{label}".encode()).hexdigest() rows.append( { "row_id": f"cm_{row_idx + 1:04d}", "audio_filename": filename, "context": label, "cat_id": cat_id, "owner_id": owner_id, "breed": breed, "breed_code": breed_code, "sex_status": sex_status, "sex_code": sex_code, "recording_session": session, "vocalization_counter": counter, "duration_sec": round(duration, 3), "sample_rate_hz": 8000, "channels": 1, "sample_width_bytes": 2, "frames": frames, "rms_energy": round(rms, 6), "peak_abs_amplitude": round(peak, 6), "zero_crossing_rate": round(zcr, 6), "spectral_centroid_hz": round(centroid, 3), "audio_sha256": sha, "source_url": SOURCE_URL, "source_doi": SOURCE_DOI, "license": SOURCE_LICENSE, "source_split": split_name, } ) row_idx += 1 return pd.DataFrame(rows) def _read_hf_raw_split(split_name: str) -> pd.DataFrame: url = ( f"https://huggingface.co/datasets/{HF_DATASET_ID}/resolve/main/data/" f"{split_name}_raw-00000-of-00001.parquet" ) df = pd.read_parquet(url) if "audio" in df.columns: df = df.drop(columns=["audio"]) df = df.copy() df["source_split"] = split_name if "row_id" not in df.columns: df.insert(0, "row_id", [f"cm_{idx + 1:04d}" for idx in range(len(df))]) return df def fetch_huggingface_manifest() -> pd.DataFrame: """Fetch the public train_raw/test_raw metadata without committing audio.""" frames = [_read_hf_raw_split(split) for split in ("train", "test")] df = pd.concat(frames, ignore_index=True) if "row_id" not in df.columns: df.insert(0, "row_id", [f"cm_{idx + 1:04d}" for idx in range(len(df))]) df["row_id"] = [f"cm_{idx + 1:04d}" for idx in range(len(df))] columns = [column for column in df.columns if column != "audio"] return df.loc[:, columns] def write_manifest(path: Path = MANIFEST_PATH, *, refresh_source: bool = False) -> pd.DataFrame: """Write the metadata manifest. When ``refresh_source`` is true, the script tries to read public Hugging Face parquet metadata. If that fails, it falls back to the canonical local manifest so checks remain runnable offline. """ ensure_repo_dirs() if path.exists() and not refresh_source: return pd.read_csv(path) if refresh_source: try: df = fetch_huggingface_manifest() except Exception: df = generate_canonical_manifest() else: df = generate_canonical_manifest() df.to_csv(path, index=False) return df def load_manifest(path: Path = MANIFEST_PATH) -> pd.DataFrame: """Load the committed manifest, generating it when absent.""" if not path.exists(): return write_manifest(path) return pd.read_csv(path) def manifest_status(df: pd.DataFrame) -> ManifestStatus: """Return compact manifest status for CLI output.""" return ManifestStatus( rows=len(df), cats=df["cat_id"].nunique(), owners=df["owner_id"].nunique(), labels=tuple(sorted(df["context"].unique())), ) def generate_split_definitions(df: pd.DataFrame) -> pd.DataFrame: """Generate random and cat-heldout split assignments.""" rows: list[dict[str, object]] = [] sorted_df = df.sort_values(["context", "cat_id", "row_id"]).reset_index(drop=True) per_label_position: dict[str, int] = {label: 0 for label in EXPECTED_LABELS} for record in sorted_df.to_dict(orient="records"): label = str(record["context"]) position = per_label_position[label] per_label_position[label] += 1 random_split = "test" if position % 4 == 0 else "train" heldout_split = str(record.get("source_split", "train")) if heldout_split.endswith("_raw"): heldout_split = heldout_split.replace("_raw", "") rows.append( { "row_id": record["row_id"], "audio_filename": record["audio_filename"], "context": label, "cat_id": record["cat_id"], "owner_id": record["owner_id"], "random_split": random_split, "cat_heldout_split": heldout_split, } ) return pd.DataFrame(rows) def write_split_definitions( df: pd.DataFrame | None = None, path: Path = SPLITS_PATH ) -> pd.DataFrame: """Write split assignments.""" ensure_repo_dirs() manifest = load_manifest() if df is None else df splits = generate_split_definitions(manifest) splits.to_csv(path, index=False) return splits def load_split_definitions(path: Path = SPLITS_PATH) -> pd.DataFrame: """Load split definitions, generating them when absent.""" if not path.exists(): return write_split_definitions() return pd.read_csv(path) def feature_frame(df: pd.DataFrame) -> pd.DataFrame: """Return only identity-blind acoustic-5 model inputs.""" return df.loc[:, list(FEATURE_COLUMNS)].astype(float) def validate_expected_labels(labels: Iterable[str]) -> bool: """Check that all expected labels are present and no surprise label appears.""" return set(labels) == set(EXPECTED_LABELS) def stable_label_codes(labels: Iterable[str]) -> np.ndarray: """Map labels to stable integer codes in EXPECTED_LABELS order.""" index = {label: idx for idx, label in enumerate(EXPECTED_LABELS)} return np.array([index[str(label)] for label in labels], dtype=np.int64)