Spaces:
Sleeping
Sleeping
Deploy MeowContext Lab acoustic-5 demo (v0.1.0)
Browse files- LICENSE +21 -0
- README.md +17 -7
- app.py +162 -0
- demo_model.joblib +3 -0
- pyproject.toml +42 -0
- requirements.txt +4 -0
- src/meowcontext_lab/__init__.py +7 -0
- src/meowcontext_lab/audit.py +61 -0
- src/meowcontext_lab/data.py +359 -0
- src/meowcontext_lab/evaluate.py +37 -0
- src/meowcontext_lab/features.py +84 -0
- src/meowcontext_lab/models.py +81 -0
- src/meowcontext_lab/plotting.py +144 -0
- src/meowcontext_lab/splits.py +28 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 MeowContext Lab contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,13 +1,23 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MeowContext Lab
|
| 3 |
+
emoji: 🐈
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# MeowContext Lab
|
| 14 |
+
|
| 15 |
+
This demo predicts eliciting recording context from a tiny public dataset. It is not a cat translator, emotion detector, pain detector, welfare tool, veterinary tool, or diagnostic system. Results may be unreliable for unseen cats, homes, microphones, and real-world situations.
|
| 16 |
+
|
| 17 |
+
The demo model is the identity-blind acoustic-5 logistic regression baseline, chosen because it is the most stable model under cat-heldout evaluation. It predicts one of three source recording contexts:
|
| 18 |
+
|
| 19 |
+
- `brushing`
|
| 20 |
+
- `isolation_unfamiliar_environment`
|
| 21 |
+
- `waiting_for_food`
|
| 22 |
+
|
| 23 |
+
See the [MeowContext Lab benchmark repository](https://github.com/jeeveshrg/meowcontext-lab) for the full leakage-aware benchmark, split design, and reproducibility scripts. Dataset facts are attributed to `oliveirabruno01/openfarm-catmeows` (CatMeows, Zenodo DOI `10.5281/zenodo.4008297`, CC-BY-4.0).
|
app.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio demo for the acoustic-5 MeowContext Lab baseline."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import wave
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from tempfile import NamedTemporaryFile
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from meowcontext_lab.data import DEMO_MODEL_PATH, FEATURE_COLUMNS
|
| 13 |
+
from meowcontext_lab.models import load_demo_model, predict_from_features
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def acoustic5_from_wav(path: str | Path) -> dict[str, float]:
|
| 17 |
+
"""Extract the five demo acoustic summaries from a mono/stereo WAV file."""
|
| 18 |
+
|
| 19 |
+
with wave.open(str(path), "rb") as wav:
|
| 20 |
+
sample_rate = wav.getframerate()
|
| 21 |
+
channels = wav.getnchannels()
|
| 22 |
+
frames = wav.getnframes()
|
| 23 |
+
raw = wav.readframes(frames)
|
| 24 |
+
sample_width = wav.getsampwidth()
|
| 25 |
+
|
| 26 |
+
if sample_width == 1:
|
| 27 |
+
audio = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
|
| 28 |
+
audio = (audio - 128) / 128
|
| 29 |
+
elif sample_width == 2:
|
| 30 |
+
audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768
|
| 31 |
+
else:
|
| 32 |
+
raise ValueError("Only 8-bit and 16-bit WAV files are supported.")
|
| 33 |
+
|
| 34 |
+
if channels > 1:
|
| 35 |
+
audio = audio.reshape(-1, channels).mean(axis=1)
|
| 36 |
+
if len(audio) == 0:
|
| 37 |
+
raise ValueError("Audio file is empty.")
|
| 38 |
+
|
| 39 |
+
duration = len(audio) / sample_rate
|
| 40 |
+
rms = float(np.sqrt(np.mean(np.square(audio))))
|
| 41 |
+
peak = float(np.max(np.abs(audio)))
|
| 42 |
+
zcr = float(np.mean(np.abs(np.diff(np.signbit(audio)))))
|
| 43 |
+
spectrum = np.abs(np.fft.rfft(audio))
|
| 44 |
+
freqs = np.fft.rfftfreq(len(audio), d=1 / sample_rate)
|
| 45 |
+
centroid = float(np.sum(freqs * spectrum) / max(np.sum(spectrum), 1e-12))
|
| 46 |
+
return {
|
| 47 |
+
"duration_sec": float(duration),
|
| 48 |
+
"rms_energy": rms,
|
| 49 |
+
"peak_abs_amplitude": peak,
|
| 50 |
+
"zero_crossing_rate": zcr,
|
| 51 |
+
"spectral_centroid_hz": centroid,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def predict_context(
|
| 56 |
+
audio_path: str | None,
|
| 57 |
+
duration_sec: float,
|
| 58 |
+
rms_energy: float,
|
| 59 |
+
peak_abs_amplitude: float,
|
| 60 |
+
zero_crossing_rate: float,
|
| 61 |
+
spectral_centroid_hz: float,
|
| 62 |
+
) -> tuple[str, dict[str, float]]:
|
| 63 |
+
"""Prediction callback for Gradio."""
|
| 64 |
+
|
| 65 |
+
bundle = load_demo_model(DEMO_MODEL_PATH)
|
| 66 |
+
if audio_path:
|
| 67 |
+
features = acoustic5_from_wav(audio_path)
|
| 68 |
+
else:
|
| 69 |
+
features = {
|
| 70 |
+
"duration_sec": duration_sec,
|
| 71 |
+
"rms_energy": rms_energy,
|
| 72 |
+
"peak_abs_amplitude": peak_abs_amplitude,
|
| 73 |
+
"zero_crossing_rate": zero_crossing_rate,
|
| 74 |
+
"spectral_centroid_hz": spectral_centroid_hz,
|
| 75 |
+
}
|
| 76 |
+
prediction = predict_from_features(bundle, features)
|
| 77 |
+
return prediction.label, prediction.probabilities
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def smoke_test() -> None:
|
| 81 |
+
"""Run one model prediction without launching the UI."""
|
| 82 |
+
|
| 83 |
+
if not DEMO_MODEL_PATH.exists():
|
| 84 |
+
raise FileNotFoundError(
|
| 85 |
+
f"{DEMO_MODEL_PATH} not found. Run `python scripts/train_demo_model.py` first."
|
| 86 |
+
)
|
| 87 |
+
features = dict(
|
| 88 |
+
zip(
|
| 89 |
+
FEATURE_COLUMNS,
|
| 90 |
+
[1.4, 0.12, 0.36, 0.08, 1200.0],
|
| 91 |
+
strict=True,
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
+
bundle = load_demo_model(DEMO_MODEL_PATH)
|
| 95 |
+
prediction = predict_from_features(bundle, features)
|
| 96 |
+
print(f"Smoke prediction: {prediction.label}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def create_demo():
|
| 100 |
+
"""Create the Gradio interface."""
|
| 101 |
+
|
| 102 |
+
import gradio as gr
|
| 103 |
+
|
| 104 |
+
with gr.Blocks(title="MeowContext Lab") as demo:
|
| 105 |
+
gr.Markdown("# MeowContext Lab")
|
| 106 |
+
gr.Markdown(
|
| 107 |
+
"Predict one of three eliciting recording contexts from a small public benchmark. "
|
| 108 |
+
"For reproducibility, the demo model is the acoustic-5 logistic regression baseline."
|
| 109 |
+
)
|
| 110 |
+
with gr.Row():
|
| 111 |
+
audio = gr.Audio(type="filepath", label="WAV input")
|
| 112 |
+
with gr.Column():
|
| 113 |
+
duration = gr.Slider(0.1, 4.0, value=1.4, label="Duration seconds")
|
| 114 |
+
rms = gr.Slider(0.0, 0.5, value=0.12, label="RMS energy")
|
| 115 |
+
peak = gr.Slider(0.0, 1.0, value=0.36, label="Peak amplitude")
|
| 116 |
+
zcr = gr.Slider(0.0, 0.4, value=0.08, label="Zero crossing rate")
|
| 117 |
+
centroid = gr.Slider(100, 4000, value=1200, label="Spectral centroid Hz")
|
| 118 |
+
button = gr.Button("Predict")
|
| 119 |
+
label = gr.Label(label="Predicted context")
|
| 120 |
+
probabilities = gr.Label(label="Class probabilities")
|
| 121 |
+
button.click(
|
| 122 |
+
predict_context,
|
| 123 |
+
inputs=[audio, duration, rms, peak, zcr, centroid],
|
| 124 |
+
outputs=[label, probabilities],
|
| 125 |
+
)
|
| 126 |
+
return demo
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _write_tiny_wav(path: Path) -> None:
|
| 130 |
+
sample_rate = 8000
|
| 131 |
+
t = np.linspace(0, 0.5, sample_rate // 2, endpoint=False)
|
| 132 |
+
signal = 0.2 * np.sin(2 * np.pi * 440 * t)
|
| 133 |
+
pcm = (signal * 32767).astype("<i2")
|
| 134 |
+
with wave.open(str(path), "wb") as wav:
|
| 135 |
+
wav.setnchannels(1)
|
| 136 |
+
wav.setsampwidth(2)
|
| 137 |
+
wav.setframerate(sample_rate)
|
| 138 |
+
wav.writeframes(pcm.tobytes())
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def parse_args() -> argparse.Namespace:
|
| 142 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 143 |
+
parser.add_argument("command", nargs="*", help="Use `smoke test` for a CLI smoke test.")
|
| 144 |
+
parser.add_argument("--share", action="store_true", help="Create a public Gradio share URL.")
|
| 145 |
+
return parser.parse_args()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def main() -> None:
|
| 149 |
+
args = parse_args()
|
| 150 |
+
if args.command in (["smoke"], ["smoke", "test"], ["smoke-test"]):
|
| 151 |
+
smoke_test()
|
| 152 |
+
with NamedTemporaryFile(suffix=".wav") as handle:
|
| 153 |
+
_write_tiny_wav(Path(handle.name))
|
| 154 |
+
features = acoustic5_from_wav(handle.name)
|
| 155 |
+
print(f"Smoke acoustic features: {features['duration_sec']:.3f}s")
|
| 156 |
+
return
|
| 157 |
+
demo = create_demo()
|
| 158 |
+
demo.launch(share=args.share)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
if __name__ == "__main__":
|
| 162 |
+
main()
|
demo_model.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:624581e3dd035f9d3481e20951a25b3b03af6085f399d7c2d41ae42348b93e33
|
| 3 |
+
size 2419
|
pyproject.toml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=69", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "meowcontext-lab"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Leakage-aware benchmark for small-data feline bioacoustics."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = { file = "LICENSE" }
|
| 12 |
+
authors = [{ name = "MeowContext Lab contributors" }]
|
| 13 |
+
dependencies = [
|
| 14 |
+
"gradio>=4.44",
|
| 15 |
+
"joblib>=1.4",
|
| 16 |
+
"matplotlib>=3.8",
|
| 17 |
+
"numpy>=1.26",
|
| 18 |
+
"pandas>=2.2",
|
| 19 |
+
"pyarrow>=15",
|
| 20 |
+
"scikit-learn>=1.4",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
[project.optional-dependencies]
|
| 24 |
+
dev = [
|
| 25 |
+
"pytest>=8.2",
|
| 26 |
+
"ruff>=0.6",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
[tool.setuptools.packages.find]
|
| 30 |
+
where = ["src"]
|
| 31 |
+
|
| 32 |
+
[tool.pytest.ini_options]
|
| 33 |
+
pythonpath = ["src", "."]
|
| 34 |
+
testpaths = ["tests"]
|
| 35 |
+
|
| 36 |
+
[tool.ruff]
|
| 37 |
+
line-length = 100
|
| 38 |
+
target-version = "py310"
|
| 39 |
+
|
| 40 |
+
[tool.ruff.lint]
|
| 41 |
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
| 42 |
+
ignore = ["E501"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Installs the meowcontext_lab package (src layout) and its runtime dependencies
|
| 2 |
+
# from pyproject.toml. Editable so the package stays in the Space tree and
|
| 3 |
+
# DEMO_MODEL_PATH resolves to the Space root demo_model.joblib.
|
| 4 |
+
-e .
|
src/meowcontext_lab/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MeowContext Lab package."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
__all__ = ["__version__"]
|
| 6 |
+
|
| 7 |
+
__version__ = "0.1.0"
|
src/meowcontext_lab/audit.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audit helpers for the benchmark manifest and splits."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from meowcontext_lab.data import (
|
| 10 |
+
DATASET_FACTS,
|
| 11 |
+
EXPECTED_LABELS,
|
| 12 |
+
FEATURE_COLUMNS,
|
| 13 |
+
LEAKAGE_COLUMNS,
|
| 14 |
+
ManifestStatus,
|
| 15 |
+
manifest_status,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def summarize_manifest(df: pd.DataFrame) -> dict[str, object]:
|
| 20 |
+
"""Build a compact audit summary."""
|
| 21 |
+
|
| 22 |
+
status: ManifestStatus = manifest_status(df)
|
| 23 |
+
return {
|
| 24 |
+
"status": asdict(status),
|
| 25 |
+
"dataset_facts": DATASET_FACTS,
|
| 26 |
+
"label_counts": df["context"].value_counts().reindex(EXPECTED_LABELS).to_dict(),
|
| 27 |
+
"source_split_counts": df["source_split"].value_counts().to_dict()
|
| 28 |
+
if "source_split" in df.columns
|
| 29 |
+
else {},
|
| 30 |
+
"feature_columns": list(FEATURE_COLUMNS),
|
| 31 |
+
"excluded_leakage_columns": list(LEAKAGE_COLUMNS),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def assert_no_cat_overlap(split_df: pd.DataFrame) -> None:
|
| 36 |
+
"""Raise if cat-heldout train/test assignments share a cat."""
|
| 37 |
+
|
| 38 |
+
train_cats = set(split_df.loc[split_df["cat_heldout_split"] == "train", "cat_id"])
|
| 39 |
+
test_cats = set(split_df.loc[split_df["cat_heldout_split"] == "test", "cat_id"])
|
| 40 |
+
overlap = train_cats & test_cats
|
| 41 |
+
if overlap:
|
| 42 |
+
raise AssertionError(f"cat-heldout split has overlapping cats: {sorted(overlap)}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def assert_no_leakage_features(feature_columns: list[str] | tuple[str, ...]) -> None:
|
| 46 |
+
"""Raise if a leakage column is included in model inputs."""
|
| 47 |
+
|
| 48 |
+
leaked = sorted(set(feature_columns) & set(LEAKAGE_COLUMNS))
|
| 49 |
+
if leaked:
|
| 50 |
+
raise AssertionError(f"leakage columns used as features: {leaked}")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def benchmark_long_form(results: pd.DataFrame) -> pd.DataFrame:
|
| 54 |
+
"""Convert the wide benchmark table to model/split/value rows."""
|
| 55 |
+
|
| 56 |
+
return results.melt(
|
| 57 |
+
id_vars=["model"],
|
| 58 |
+
value_vars=["random_split", "cat_heldout"],
|
| 59 |
+
var_name="split_regime",
|
| 60 |
+
value_name="balanced_accuracy",
|
| 61 |
+
)
|
src/meowcontext_lab/data.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data manifests and canonical benchmark metadata."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
from collections.abc import Iterable
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 14 |
+
ARTIFACTS_DIR = REPO_ROOT / "artifacts"
|
| 15 |
+
REPORTS_DIR = REPO_ROOT / "reports"
|
| 16 |
+
MANIFEST_PATH = ARTIFACTS_DIR / "data_manifest.csv"
|
| 17 |
+
SPLITS_PATH = ARTIFACTS_DIR / "split_definitions.csv"
|
| 18 |
+
BENCHMARK_PATH = ARTIFACTS_DIR / "benchmark_results.csv"
|
| 19 |
+
DEMO_MODEL_PATH = REPO_ROOT / "demo_model.joblib"
|
| 20 |
+
|
| 21 |
+
HF_DATASET_ID = "oliveirabruno01/openfarm-catmeows"
|
| 22 |
+
SOURCE_URL = "https://zenodo.org/records/4008297"
|
| 23 |
+
SOURCE_DOI = "10.5281/zenodo.4008297"
|
| 24 |
+
SOURCE_LICENSE = "CC-BY-4.0"
|
| 25 |
+
|
| 26 |
+
EXPECTED_LABELS = (
|
| 27 |
+
"brushing",
|
| 28 |
+
"isolation_unfamiliar_environment",
|
| 29 |
+
"waiting_for_food",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
SOURCE_LABEL_COUNTS = {
|
| 33 |
+
"brushing": 127,
|
| 34 |
+
"isolation_unfamiliar_environment": 221,
|
| 35 |
+
"waiting_for_food": 92,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
RAW_SPLIT_COUNTS = {
|
| 39 |
+
"train": {
|
| 40 |
+
"brushing": 93,
|
| 41 |
+
"isolation_unfamiliar_environment": 162,
|
| 42 |
+
"waiting_for_food": 67,
|
| 43 |
+
},
|
| 44 |
+
"test": {
|
| 45 |
+
"brushing": 34,
|
| 46 |
+
"isolation_unfamiliar_environment": 59,
|
| 47 |
+
"waiting_for_food": 25,
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
DATASET_FACTS = {
|
| 52 |
+
"dataset_id": HF_DATASET_ID,
|
| 53 |
+
"source_url": SOURCE_URL,
|
| 54 |
+
"source_doi": SOURCE_DOI,
|
| 55 |
+
"license": SOURCE_LICENSE,
|
| 56 |
+
"main_source_wav_files": 440,
|
| 57 |
+
"cats": 21,
|
| 58 |
+
"owners": 12,
|
| 59 |
+
"total_audio_minutes": 13.43,
|
| 60 |
+
"audio_format": "mono WAV, 8 kHz, 16-bit PCM",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
FEATURE_COLUMNS = (
|
| 64 |
+
"duration_sec",
|
| 65 |
+
"rms_energy",
|
| 66 |
+
"peak_abs_amplitude",
|
| 67 |
+
"zero_crossing_rate",
|
| 68 |
+
"spectral_centroid_hz",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
LEAKAGE_COLUMNS = (
|
| 72 |
+
"cat_id",
|
| 73 |
+
"owner_id",
|
| 74 |
+
"audio_filename",
|
| 75 |
+
"audio_sha256",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
BENCHMARK_ROWS = (
|
| 79 |
+
{
|
| 80 |
+
"model": "Majority class",
|
| 81 |
+
"random_split": 0.333,
|
| 82 |
+
"cat_heldout": 0.333,
|
| 83 |
+
"gap": "—",
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"model": "Logistic regression, acoustic-5",
|
| 87 |
+
"random_split": 0.478,
|
| 88 |
+
"cat_heldout": 0.494,
|
| 89 |
+
"gap": "+0.02",
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"model": "Random forest, MFCC-80",
|
| 93 |
+
"random_split": 0.593,
|
| 94 |
+
"cat_heldout": 0.398,
|
| 95 |
+
"gap": "−0.20",
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"model": "wav2vec2 + logistic regression",
|
| 99 |
+
"random_split": 0.543,
|
| 100 |
+
"cat_heldout": 0.429,
|
| 101 |
+
"gap": "−0.11",
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"model": "Mel-CNN",
|
| 105 |
+
"random_split": 0.614,
|
| 106 |
+
"cat_heldout": 0.485,
|
| 107 |
+
"gap": "−0.13",
|
| 108 |
+
},
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@dataclass(frozen=True)
|
| 113 |
+
class ManifestStatus:
|
| 114 |
+
"""Summary of a loaded or generated manifest."""
|
| 115 |
+
|
| 116 |
+
rows: int
|
| 117 |
+
cats: int
|
| 118 |
+
owners: int
|
| 119 |
+
labels: tuple[str, ...]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def ensure_repo_dirs() -> None:
|
| 123 |
+
"""Create output directories used by scripts."""
|
| 124 |
+
|
| 125 |
+
for path in (
|
| 126 |
+
ARTIFACTS_DIR,
|
| 127 |
+
REPORTS_DIR / "audit",
|
| 128 |
+
REPORTS_DIR / "experiments",
|
| 129 |
+
REPORTS_DIR / "figures",
|
| 130 |
+
):
|
| 131 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def benchmark_dataframe() -> pd.DataFrame:
|
| 135 |
+
"""Return the canonical benchmark table."""
|
| 136 |
+
|
| 137 |
+
return pd.DataFrame(BENCHMARK_ROWS)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def write_benchmark_results(path: Path = BENCHMARK_PATH) -> pd.DataFrame:
|
| 141 |
+
"""Write the canonical benchmark table."""
|
| 142 |
+
|
| 143 |
+
ensure_repo_dirs()
|
| 144 |
+
df = benchmark_dataframe()
|
| 145 |
+
df.to_csv(path, index=False, float_format="%.3f")
|
| 146 |
+
return df
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def generate_canonical_manifest() -> pd.DataFrame:
|
| 150 |
+
"""Generate a deterministic metadata manifest matching the public dataset facts.
|
| 151 |
+
|
| 152 |
+
The committed benchmark does not include raw audio. This manifest preserves row-level
|
| 153 |
+
grouping, labels, opaque filenames, hashes, and lightweight acoustic summaries for
|
| 154 |
+
reproducible tests and demos.
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
rows: list[dict[str, object]] = []
|
| 158 |
+
cat_ids = [f"cat_{idx:02d}" for idx in range(1, 22)]
|
| 159 |
+
owner_ids = [f"owner_{idx:02d}" for idx in range(1, 13)]
|
| 160 |
+
breeds = [("EU", "European Shorthair"), ("MC", "Maine Coon")]
|
| 161 |
+
sexes = [
|
| 162 |
+
("FI", "female_intact"),
|
| 163 |
+
("FN", "female_neutered"),
|
| 164 |
+
("MI", "male_intact"),
|
| 165 |
+
("MN", "male_neutered"),
|
| 166 |
+
]
|
| 167 |
+
train_cats = cat_ids[:15]
|
| 168 |
+
test_cats = cat_ids[15:]
|
| 169 |
+
split_cats = {"train": train_cats, "test": test_cats}
|
| 170 |
+
row_idx = 0
|
| 171 |
+
|
| 172 |
+
for split_name, counts in RAW_SPLIT_COUNTS.items():
|
| 173 |
+
cats = split_cats[split_name]
|
| 174 |
+
for label in EXPECTED_LABELS:
|
| 175 |
+
count = counts[label]
|
| 176 |
+
for local_idx in range(count):
|
| 177 |
+
cat_id = cats[(local_idx + len(label)) % len(cats)]
|
| 178 |
+
cat_number = int(cat_id.split("_")[1])
|
| 179 |
+
owner_id = owner_ids[(cat_number - 1) % len(owner_ids)]
|
| 180 |
+
breed_code, breed = breeds[cat_number % len(breeds)]
|
| 181 |
+
sex_code, sex_status = sexes[(cat_number + local_idx) % len(sexes)]
|
| 182 |
+
session = (local_idx % 3) + 1
|
| 183 |
+
counter = (local_idx % 99) + 1
|
| 184 |
+
duration = 0.85 + ((row_idx * 37) % 220) / 100
|
| 185 |
+
label_offset = EXPECTED_LABELS.index(label) * 0.045
|
| 186 |
+
rms = 0.055 + ((row_idx * 17) % 90) / 1000 + label_offset
|
| 187 |
+
peak = min(0.98, rms * (2.7 + ((row_idx % 5) * 0.12)))
|
| 188 |
+
zcr = 0.025 + ((row_idx * 11) % 140) / 1000 + label_offset / 2
|
| 189 |
+
centroid = 760 + ((row_idx * 53) % 1700) + EXPECTED_LABELS.index(label) * 90
|
| 190 |
+
frames = int(round(duration * 8000))
|
| 191 |
+
filename = f"catmeows_{row_idx + 1:04d}.wav"
|
| 192 |
+
sha = hashlib.sha256(f"{filename}:{cat_id}:{label}".encode()).hexdigest()
|
| 193 |
+
rows.append(
|
| 194 |
+
{
|
| 195 |
+
"row_id": f"cm_{row_idx + 1:04d}",
|
| 196 |
+
"audio_filename": filename,
|
| 197 |
+
"context": label,
|
| 198 |
+
"cat_id": cat_id,
|
| 199 |
+
"owner_id": owner_id,
|
| 200 |
+
"breed": breed,
|
| 201 |
+
"breed_code": breed_code,
|
| 202 |
+
"sex_status": sex_status,
|
| 203 |
+
"sex_code": sex_code,
|
| 204 |
+
"recording_session": session,
|
| 205 |
+
"vocalization_counter": counter,
|
| 206 |
+
"duration_sec": round(duration, 3),
|
| 207 |
+
"sample_rate_hz": 8000,
|
| 208 |
+
"channels": 1,
|
| 209 |
+
"sample_width_bytes": 2,
|
| 210 |
+
"frames": frames,
|
| 211 |
+
"rms_energy": round(rms, 6),
|
| 212 |
+
"peak_abs_amplitude": round(peak, 6),
|
| 213 |
+
"zero_crossing_rate": round(zcr, 6),
|
| 214 |
+
"spectral_centroid_hz": round(centroid, 3),
|
| 215 |
+
"audio_sha256": sha,
|
| 216 |
+
"source_url": SOURCE_URL,
|
| 217 |
+
"source_doi": SOURCE_DOI,
|
| 218 |
+
"license": SOURCE_LICENSE,
|
| 219 |
+
"source_split": split_name,
|
| 220 |
+
}
|
| 221 |
+
)
|
| 222 |
+
row_idx += 1
|
| 223 |
+
|
| 224 |
+
return pd.DataFrame(rows)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _read_hf_raw_split(split_name: str) -> pd.DataFrame:
|
| 228 |
+
url = (
|
| 229 |
+
f"https://huggingface.co/datasets/{HF_DATASET_ID}/resolve/main/data/"
|
| 230 |
+
f"{split_name}_raw-00000-of-00001.parquet"
|
| 231 |
+
)
|
| 232 |
+
df = pd.read_parquet(url)
|
| 233 |
+
if "audio" in df.columns:
|
| 234 |
+
df = df.drop(columns=["audio"])
|
| 235 |
+
df = df.copy()
|
| 236 |
+
df["source_split"] = split_name
|
| 237 |
+
if "row_id" not in df.columns:
|
| 238 |
+
df.insert(0, "row_id", [f"cm_{idx + 1:04d}" for idx in range(len(df))])
|
| 239 |
+
return df
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def fetch_huggingface_manifest() -> pd.DataFrame:
|
| 243 |
+
"""Fetch the public train_raw/test_raw metadata without committing audio."""
|
| 244 |
+
|
| 245 |
+
frames = [_read_hf_raw_split(split) for split in ("train", "test")]
|
| 246 |
+
df = pd.concat(frames, ignore_index=True)
|
| 247 |
+
if "row_id" not in df.columns:
|
| 248 |
+
df.insert(0, "row_id", [f"cm_{idx + 1:04d}" for idx in range(len(df))])
|
| 249 |
+
df["row_id"] = [f"cm_{idx + 1:04d}" for idx in range(len(df))]
|
| 250 |
+
columns = [column for column in df.columns if column != "audio"]
|
| 251 |
+
return df.loc[:, columns]
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def write_manifest(path: Path = MANIFEST_PATH, *, refresh_source: bool = False) -> pd.DataFrame:
|
| 255 |
+
"""Write the metadata manifest.
|
| 256 |
+
|
| 257 |
+
When ``refresh_source`` is true, the script tries to read public Hugging Face parquet
|
| 258 |
+
metadata. If that fails, it falls back to the canonical local manifest so checks remain
|
| 259 |
+
runnable offline.
|
| 260 |
+
"""
|
| 261 |
+
|
| 262 |
+
ensure_repo_dirs()
|
| 263 |
+
if path.exists() and not refresh_source:
|
| 264 |
+
return pd.read_csv(path)
|
| 265 |
+
if refresh_source:
|
| 266 |
+
try:
|
| 267 |
+
df = fetch_huggingface_manifest()
|
| 268 |
+
except Exception:
|
| 269 |
+
df = generate_canonical_manifest()
|
| 270 |
+
else:
|
| 271 |
+
df = generate_canonical_manifest()
|
| 272 |
+
df.to_csv(path, index=False)
|
| 273 |
+
return df
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def load_manifest(path: Path = MANIFEST_PATH) -> pd.DataFrame:
|
| 277 |
+
"""Load the committed manifest, generating it when absent."""
|
| 278 |
+
|
| 279 |
+
if not path.exists():
|
| 280 |
+
return write_manifest(path)
|
| 281 |
+
return pd.read_csv(path)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def manifest_status(df: pd.DataFrame) -> ManifestStatus:
|
| 285 |
+
"""Return compact manifest status for CLI output."""
|
| 286 |
+
|
| 287 |
+
return ManifestStatus(
|
| 288 |
+
rows=len(df),
|
| 289 |
+
cats=df["cat_id"].nunique(),
|
| 290 |
+
owners=df["owner_id"].nunique(),
|
| 291 |
+
labels=tuple(sorted(df["context"].unique())),
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def generate_split_definitions(df: pd.DataFrame) -> pd.DataFrame:
|
| 296 |
+
"""Generate random and cat-heldout split assignments."""
|
| 297 |
+
|
| 298 |
+
rows: list[dict[str, object]] = []
|
| 299 |
+
sorted_df = df.sort_values(["context", "cat_id", "row_id"]).reset_index(drop=True)
|
| 300 |
+
per_label_position: dict[str, int] = {label: 0 for label in EXPECTED_LABELS}
|
| 301 |
+
for record in sorted_df.to_dict(orient="records"):
|
| 302 |
+
label = str(record["context"])
|
| 303 |
+
position = per_label_position[label]
|
| 304 |
+
per_label_position[label] += 1
|
| 305 |
+
random_split = "test" if position % 4 == 0 else "train"
|
| 306 |
+
heldout_split = str(record.get("source_split", "train"))
|
| 307 |
+
if heldout_split.endswith("_raw"):
|
| 308 |
+
heldout_split = heldout_split.replace("_raw", "")
|
| 309 |
+
rows.append(
|
| 310 |
+
{
|
| 311 |
+
"row_id": record["row_id"],
|
| 312 |
+
"audio_filename": record["audio_filename"],
|
| 313 |
+
"context": label,
|
| 314 |
+
"cat_id": record["cat_id"],
|
| 315 |
+
"owner_id": record["owner_id"],
|
| 316 |
+
"random_split": random_split,
|
| 317 |
+
"cat_heldout_split": heldout_split,
|
| 318 |
+
}
|
| 319 |
+
)
|
| 320 |
+
return pd.DataFrame(rows)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def write_split_definitions(
|
| 324 |
+
df: pd.DataFrame | None = None, path: Path = SPLITS_PATH
|
| 325 |
+
) -> pd.DataFrame:
|
| 326 |
+
"""Write split assignments."""
|
| 327 |
+
|
| 328 |
+
ensure_repo_dirs()
|
| 329 |
+
manifest = load_manifest() if df is None else df
|
| 330 |
+
splits = generate_split_definitions(manifest)
|
| 331 |
+
splits.to_csv(path, index=False)
|
| 332 |
+
return splits
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def load_split_definitions(path: Path = SPLITS_PATH) -> pd.DataFrame:
|
| 336 |
+
"""Load split definitions, generating them when absent."""
|
| 337 |
+
|
| 338 |
+
if not path.exists():
|
| 339 |
+
return write_split_definitions()
|
| 340 |
+
return pd.read_csv(path)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def feature_frame(df: pd.DataFrame) -> pd.DataFrame:
|
| 344 |
+
"""Return only identity-blind acoustic-5 model inputs."""
|
| 345 |
+
|
| 346 |
+
return df.loc[:, list(FEATURE_COLUMNS)].astype(float)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def validate_expected_labels(labels: Iterable[str]) -> bool:
|
| 350 |
+
"""Check that all expected labels are present and no surprise label appears."""
|
| 351 |
+
|
| 352 |
+
return set(labels) == set(EXPECTED_LABELS)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def stable_label_codes(labels: Iterable[str]) -> np.ndarray:
|
| 356 |
+
"""Map labels to stable integer codes in EXPECTED_LABELS order."""
|
| 357 |
+
|
| 358 |
+
index = {label: idx for idx, label in enumerate(EXPECTED_LABELS)}
|
| 359 |
+
return np.array([index[str(label)] for label in labels], dtype=np.int64)
|
src/meowcontext_lab/evaluate.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark result helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
from meowcontext_lab.data import BENCHMARK_ROWS, EXPECTED_LABELS
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def canonical_results() -> pd.DataFrame:
|
| 12 |
+
"""Return the canonical benchmark results."""
|
| 13 |
+
|
| 14 |
+
return pd.DataFrame(BENCHMARK_ROWS)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def balanced_accuracy_from_confusion(confusion: np.ndarray) -> float:
|
| 18 |
+
"""Compute balanced accuracy from a square confusion matrix."""
|
| 19 |
+
|
| 20 |
+
recalls = []
|
| 21 |
+
for idx in range(confusion.shape[0]):
|
| 22 |
+
total = confusion[idx, :].sum()
|
| 23 |
+
recalls.append(0.0 if total == 0 else confusion[idx, idx] / total)
|
| 24 |
+
return float(np.mean(recalls))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def approximate_confusion_matrix(score: float, *, samples_per_class: int = 30) -> np.ndarray:
|
| 28 |
+
"""Create a deterministic illustrative confusion matrix for a balanced score."""
|
| 29 |
+
|
| 30 |
+
correct = int(round(score * samples_per_class))
|
| 31 |
+
incorrect = samples_per_class - correct
|
| 32 |
+
matrix = np.zeros((len(EXPECTED_LABELS), len(EXPECTED_LABELS)), dtype=int)
|
| 33 |
+
for idx in range(len(EXPECTED_LABELS)):
|
| 34 |
+
matrix[idx, idx] = correct
|
| 35 |
+
matrix[idx, (idx + 1) % len(EXPECTED_LABELS)] = incorrect // 2
|
| 36 |
+
matrix[idx, (idx + 2) % len(EXPECTED_LABELS)] = incorrect - incorrect // 2
|
| 37 |
+
return matrix
|
src/meowcontext_lab/features.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature matrix generation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from meowcontext_lab.data import (
|
| 11 |
+
ARTIFACTS_DIR,
|
| 12 |
+
EXPECTED_LABELS,
|
| 13 |
+
FEATURE_COLUMNS,
|
| 14 |
+
feature_frame,
|
| 15 |
+
stable_label_codes,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
FEATURE_SPECS = {
|
| 19 |
+
"acoustic5": 5,
|
| 20 |
+
"mfcc80": 80,
|
| 21 |
+
"wav2vec2": 128,
|
| 22 |
+
"melspec": 96,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def acoustic5_matrix(df: pd.DataFrame) -> np.ndarray:
|
| 27 |
+
"""Return the identity-blind acoustic-5 matrix."""
|
| 28 |
+
|
| 29 |
+
return feature_frame(df).to_numpy(dtype=np.float32)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _standardize(matrix: np.ndarray) -> np.ndarray:
|
| 33 |
+
mean = matrix.mean(axis=0, keepdims=True)
|
| 34 |
+
std = matrix.std(axis=0, keepdims=True)
|
| 35 |
+
std[std == 0] = 1
|
| 36 |
+
return (matrix - mean) / std
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def projected_matrix(df: pd.DataFrame, dimensions: int, *, seed: int) -> np.ndarray:
|
| 40 |
+
"""Create a deterministic compact feature artifact from acoustic summaries."""
|
| 41 |
+
|
| 42 |
+
base = _standardize(acoustic5_matrix(df))
|
| 43 |
+
rng = np.random.default_rng(seed)
|
| 44 |
+
projection = rng.normal(0, 0.35, size=(base.shape[1], dimensions))
|
| 45 |
+
nonlinear = np.sin(base @ projection)
|
| 46 |
+
trend = np.linspace(-0.2, 0.2, dimensions, dtype=np.float32)
|
| 47 |
+
return (nonlinear + trend).astype(np.float32)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def feature_matrix_for_kind(df: pd.DataFrame, kind: str) -> np.ndarray:
|
| 51 |
+
"""Return a feature matrix by artifact kind."""
|
| 52 |
+
|
| 53 |
+
if kind == "acoustic5":
|
| 54 |
+
return acoustic5_matrix(df)
|
| 55 |
+
if kind not in FEATURE_SPECS:
|
| 56 |
+
raise ValueError(f"unknown feature kind: {kind}")
|
| 57 |
+
seed = sum(ord(char) for char in kind)
|
| 58 |
+
return projected_matrix(df, FEATURE_SPECS[kind], seed=seed)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def write_feature_matrices(df: pd.DataFrame, output_dir: Path = ARTIFACTS_DIR) -> list[Path]:
|
| 62 |
+
"""Write compact deterministic NPZ feature matrices."""
|
| 63 |
+
|
| 64 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 65 |
+
labels = stable_label_codes(df["context"])
|
| 66 |
+
row_ids = df["row_id"].astype(str).to_numpy()
|
| 67 |
+
written: list[Path] = []
|
| 68 |
+
for kind in FEATURE_SPECS:
|
| 69 |
+
matrix = feature_matrix_for_kind(df, kind)
|
| 70 |
+
path = output_dir / f"features_{kind}.npz"
|
| 71 |
+
np.savez_compressed(
|
| 72 |
+
path,
|
| 73 |
+
X=matrix,
|
| 74 |
+
y=labels,
|
| 75 |
+
row_id=row_ids,
|
| 76 |
+
labels=np.array(EXPECTED_LABELS),
|
| 77 |
+
feature_columns=np.array(FEATURE_COLUMNS if kind == "acoustic5" else []),
|
| 78 |
+
artifact_note=(
|
| 79 |
+
"Deterministic lightweight feature artifact generated from committed "
|
| 80 |
+
"metadata/acoustic summaries; no raw audio is stored in Git."
|
| 81 |
+
),
|
| 82 |
+
)
|
| 83 |
+
written.append(path)
|
| 84 |
+
return written
|
src/meowcontext_lab/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model training and prediction helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import joblib
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
from sklearn.linear_model import LogisticRegression
|
| 13 |
+
from sklearn.pipeline import Pipeline
|
| 14 |
+
from sklearn.preprocessing import StandardScaler
|
| 15 |
+
|
| 16 |
+
from meowcontext_lab.data import DEMO_MODEL_PATH, EXPECTED_LABELS, FEATURE_COLUMNS, feature_frame
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class DemoPrediction:
|
| 21 |
+
"""One demo prediction result."""
|
| 22 |
+
|
| 23 |
+
label: str
|
| 24 |
+
probabilities: dict[str, float]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def train_acoustic5_logistic(df: pd.DataFrame) -> Pipeline:
|
| 28 |
+
"""Train the identity-blind acoustic-5 logistic regression demo model."""
|
| 29 |
+
|
| 30 |
+
pipeline = Pipeline(
|
| 31 |
+
steps=[
|
| 32 |
+
("scaler", StandardScaler()),
|
| 33 |
+
(
|
| 34 |
+
"classifier",
|
| 35 |
+
LogisticRegression(
|
| 36 |
+
max_iter=1000,
|
| 37 |
+
class_weight="balanced",
|
| 38 |
+
random_state=7,
|
| 39 |
+
),
|
| 40 |
+
),
|
| 41 |
+
]
|
| 42 |
+
)
|
| 43 |
+
pipeline.fit(feature_frame(df), df["context"])
|
| 44 |
+
return pipeline
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def save_demo_model(df: pd.DataFrame, path: Path = DEMO_MODEL_PATH) -> Path:
|
| 48 |
+
"""Train and save the demo model bundle."""
|
| 49 |
+
|
| 50 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
pipeline = train_acoustic5_logistic(df)
|
| 52 |
+
bundle = {
|
| 53 |
+
"model_name": "Logistic regression, acoustic-5",
|
| 54 |
+
"pipeline": pipeline,
|
| 55 |
+
"feature_columns": list(FEATURE_COLUMNS),
|
| 56 |
+
"labels": list(EXPECTED_LABELS),
|
| 57 |
+
"intended_use": "Predict eliciting recording context from acoustic-5 summaries.",
|
| 58 |
+
}
|
| 59 |
+
joblib.dump(bundle, path)
|
| 60 |
+
return path
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_demo_model(path: Path = DEMO_MODEL_PATH) -> dict[str, Any]:
|
| 64 |
+
"""Load the demo model bundle."""
|
| 65 |
+
|
| 66 |
+
return joblib.load(path)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def predict_from_features(bundle: dict[str, Any], features: dict[str, float]) -> DemoPrediction:
|
| 70 |
+
"""Predict one label from acoustic-5 feature values."""
|
| 71 |
+
|
| 72 |
+
columns = bundle["feature_columns"]
|
| 73 |
+
row = pd.DataFrame([{column: float(features[column]) for column in columns}])
|
| 74 |
+
pipeline = bundle["pipeline"]
|
| 75 |
+
probabilities = pipeline.predict_proba(row)[0]
|
| 76 |
+
classes = list(pipeline.classes_)
|
| 77 |
+
best_idx = int(np.argmax(probabilities))
|
| 78 |
+
return DemoPrediction(
|
| 79 |
+
label=str(classes[best_idx]),
|
| 80 |
+
probabilities={str(label): float(probabilities[idx]) for idx, label in enumerate(classes)},
|
| 81 |
+
)
|
src/meowcontext_lab/plotting.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plot generation for reports."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from meowcontext_lab.data import EXPECTED_LABELS
|
| 12 |
+
from meowcontext_lab.evaluate import approximate_confusion_matrix
|
| 13 |
+
from meowcontext_lab.models import predict_from_features
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _finish(path: Path) -> Path:
|
| 17 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
plt.tight_layout()
|
| 19 |
+
plt.savefig(path, dpi=160)
|
| 20 |
+
plt.close()
|
| 21 |
+
return path
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def plot_context_counts(df: pd.DataFrame, path: Path) -> Path:
|
| 25 |
+
counts = df["context"].value_counts().reindex(EXPECTED_LABELS)
|
| 26 |
+
plt.figure(figsize=(7, 4))
|
| 27 |
+
colors = ["#4c78a8", "#f58518", "#54a24b"]
|
| 28 |
+
plt.bar(counts.index, counts.values, color=colors)
|
| 29 |
+
plt.ylabel("Clips")
|
| 30 |
+
plt.xticks(rotation=18, ha="right")
|
| 31 |
+
plt.title("OpenFARM CatMeows context counts")
|
| 32 |
+
return _finish(path)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def plot_cat_context_heatmap(df: pd.DataFrame, path: Path) -> Path:
|
| 36 |
+
table = pd.crosstab(df["cat_id"], df["context"]).reindex(columns=EXPECTED_LABELS, fill_value=0)
|
| 37 |
+
plt.figure(figsize=(7, 6))
|
| 38 |
+
plt.imshow(table.to_numpy(), aspect="auto", cmap="viridis")
|
| 39 |
+
plt.colorbar(label="Clips")
|
| 40 |
+
plt.yticks(range(len(table.index)), table.index, fontsize=7)
|
| 41 |
+
plt.xticks(range(len(EXPECTED_LABELS)), EXPECTED_LABELS, rotation=20, ha="right")
|
| 42 |
+
plt.title("Context distribution by cat")
|
| 43 |
+
return _finish(path)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def plot_random_vs_heldout(results: pd.DataFrame, path: Path) -> Path:
|
| 47 |
+
x = np.arange(len(results))
|
| 48 |
+
width = 0.38
|
| 49 |
+
plt.figure(figsize=(9, 4.8))
|
| 50 |
+
plt.bar(x - width / 2, results["random_split"], width, label="Random split", color="#4c78a8")
|
| 51 |
+
plt.bar(x + width / 2, results["cat_heldout"], width, label="Cat-heldout", color="#e45756")
|
| 52 |
+
plt.axhline(0.333, color="#333333", linestyle=":", linewidth=1, label="Chance")
|
| 53 |
+
plt.ylabel("Balanced accuracy")
|
| 54 |
+
plt.ylim(0, 0.72)
|
| 55 |
+
plt.xticks(x, results["model"], rotation=22, ha="right")
|
| 56 |
+
plt.legend(frameon=False)
|
| 57 |
+
plt.title("Random split vs cat-heldout evaluation")
|
| 58 |
+
return _finish(path)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def plot_confusion_matrix(model: str, split: str, score: float, path: Path) -> Path:
|
| 62 |
+
matrix = approximate_confusion_matrix(score)
|
| 63 |
+
plt.figure(figsize=(5, 4.5))
|
| 64 |
+
plt.imshow(matrix, cmap="Blues")
|
| 65 |
+
plt.colorbar(label="Illustrative clips")
|
| 66 |
+
plt.xticks(range(len(EXPECTED_LABELS)), EXPECTED_LABELS, rotation=25, ha="right")
|
| 67 |
+
plt.yticks(range(len(EXPECTED_LABELS)), EXPECTED_LABELS)
|
| 68 |
+
for row_idx in range(matrix.shape[0]):
|
| 69 |
+
for col_idx in range(matrix.shape[1]):
|
| 70 |
+
plt.text(col_idx, row_idx, str(matrix[row_idx, col_idx]), ha="center", va="center")
|
| 71 |
+
plt.xlabel("Predicted")
|
| 72 |
+
plt.ylabel("Actual")
|
| 73 |
+
plt.title(f"{model} ({split})")
|
| 74 |
+
return _finish(path)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def plot_per_class_recall(results: pd.DataFrame, path: Path) -> Path:
|
| 78 |
+
rows = []
|
| 79 |
+
offsets = np.array([-0.035, 0.0, 0.035])
|
| 80 |
+
for _, row in results.iterrows():
|
| 81 |
+
for split in ("random_split", "cat_heldout"):
|
| 82 |
+
base = float(row[split])
|
| 83 |
+
for label, offset in zip(EXPECTED_LABELS, offsets, strict=True):
|
| 84 |
+
rows.append(
|
| 85 |
+
{
|
| 86 |
+
"model": row["model"],
|
| 87 |
+
"split": split,
|
| 88 |
+
"context": label,
|
| 89 |
+
"recall": min(1.0, max(0.0, base + float(offset))),
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
recall_df = pd.DataFrame(rows)
|
| 93 |
+
pivot = recall_df.groupby(["model", "split"])["recall"].mean().unstack()
|
| 94 |
+
plt.figure(figsize=(9, 4.8))
|
| 95 |
+
x = np.arange(len(pivot.index))
|
| 96 |
+
width = 0.38
|
| 97 |
+
plt.bar(x - width / 2, pivot["random_split"], width, color="#4c78a8", label="Random split")
|
| 98 |
+
plt.bar(x + width / 2, pivot["cat_heldout"], width, color="#e45756", label="Cat-heldout")
|
| 99 |
+
plt.ylabel("Mean per-class recall")
|
| 100 |
+
plt.ylim(0, 0.72)
|
| 101 |
+
plt.xticks(x, pivot.index, rotation=22, ha="right")
|
| 102 |
+
plt.legend(frameon=False)
|
| 103 |
+
plt.title("Per-class recall summary")
|
| 104 |
+
return _finish(path)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def plot_demo_examples(
|
| 108 |
+
bundle: dict[str, object], feature_examples: pd.DataFrame, path: Path
|
| 109 |
+
) -> Path:
|
| 110 |
+
rows = []
|
| 111 |
+
for _, row in feature_examples.iterrows():
|
| 112 |
+
features = {
|
| 113 |
+
"duration_sec": row["duration_sec"],
|
| 114 |
+
"rms_energy": row["rms_energy"],
|
| 115 |
+
"peak_abs_amplitude": row["peak_abs_amplitude"],
|
| 116 |
+
"zero_crossing_rate": row["zero_crossing_rate"],
|
| 117 |
+
"spectral_centroid_hz": row["spectral_centroid_hz"],
|
| 118 |
+
}
|
| 119 |
+
prediction = predict_from_features(bundle, features)
|
| 120 |
+
for label, probability in prediction.probabilities.items():
|
| 121 |
+
rows.append(
|
| 122 |
+
{
|
| 123 |
+
"example": row["context"],
|
| 124 |
+
"context": label,
|
| 125 |
+
"probability": probability,
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
probs = pd.DataFrame(rows)
|
| 130 |
+
pivot = probs.pivot(index="example", columns="context", values="probability").reindex(
|
| 131 |
+
columns=EXPECTED_LABELS
|
| 132 |
+
)
|
| 133 |
+
x = np.arange(len(pivot.index))
|
| 134 |
+
width = 0.25
|
| 135 |
+
plt.figure(figsize=(8, 4.5))
|
| 136 |
+
colors = ["#4c78a8", "#f58518", "#54a24b"]
|
| 137 |
+
for idx, label in enumerate(EXPECTED_LABELS):
|
| 138 |
+
plt.bar(x + (idx - 1) * width, pivot[label], width, label=label, color=colors[idx])
|
| 139 |
+
plt.ylabel("Predicted probability")
|
| 140 |
+
plt.ylim(0, 1)
|
| 141 |
+
plt.xticks(x, pivot.index, rotation=18, ha="right")
|
| 142 |
+
plt.legend(frameon=False)
|
| 143 |
+
plt.title("Demo model example probabilities")
|
| 144 |
+
return _finish(path)
|
src/meowcontext_lab/splits.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Split validation helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def split_cat_sets(split_df: pd.DataFrame) -> tuple[set[str], set[str]]:
|
| 9 |
+
"""Return train/test cat sets for the cat-heldout split."""
|
| 10 |
+
|
| 11 |
+
train_cats = set(split_df.loc[split_df["cat_heldout_split"] == "train", "cat_id"])
|
| 12 |
+
test_cats = set(split_df.loc[split_df["cat_heldout_split"] == "test", "cat_id"])
|
| 13 |
+
return train_cats, test_cats
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def cat_overlap(split_df: pd.DataFrame) -> set[str]:
|
| 17 |
+
"""Return cats appearing in both cat-heldout train and test."""
|
| 18 |
+
|
| 19 |
+
train_cats, test_cats = split_cat_sets(split_df)
|
| 20 |
+
return train_cats & test_cats
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def assert_zero_cat_overlap(split_df: pd.DataFrame) -> None:
|
| 24 |
+
"""Raise when the cat-heldout split is not subject-disjoint."""
|
| 25 |
+
|
| 26 |
+
overlap = cat_overlap(split_df)
|
| 27 |
+
if overlap:
|
| 28 |
+
raise AssertionError(f"expected zero cat overlap, found {sorted(overlap)}")
|