jeeveshrg commited on
Commit
2199405
·
verified ·
1 Parent(s): db41b0a

Deploy MeowContext Lab acoustic-5 demo (v0.1.0)

Browse files
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: Meowcontext Lab
3
- emoji: 🏃
4
- colorFrom: gray
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
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)}")