heart-sound-classifier / src /phase1_data_inventory.py
Nishchal
Heart Sound CNN Classifier — EfficientNet-B0, 81% accuracy, Gradio app
13e4427
Raw
History Blame Contribute Delete
7.84 kB
"""
Phase 1 — Dataset Acquisition & Inventory
Parse all REFERENCE.csv files, build master label dataframe, validate files.
"""
import os
import logging
from pathlib import Path
from typing import Optional
import pandas as pd
import soundfile as sf
from src.config import (
TRAINING_DIRS, VALIDATION_DIR, SAMPLE_RATE,
LABEL_MAP, LABEL_NAMES, OUTPUT_DIR,
)
logger = logging.getLogger(__name__)
def parse_reference_csv(csv_path: Path) -> pd.DataFrame:
"""Parse a single REFERENCE.csv file.
Args:
csv_path: Path to REFERENCE.csv (two columns: filename, label).
Returns:
DataFrame with columns [filename, label, database, wav_path].
Raises:
FileNotFoundError: If csv_path does not exist.
ValueError: If CSV has unexpected format.
"""
if not csv_path.exists():
raise FileNotFoundError(f"REFERENCE.csv not found: {csv_path}")
df = pd.read_csv(csv_path, header=None, names=["filename", "label"])
if df.shape[1] != 2:
raise ValueError(f"Expected 2 columns, got {df.shape[1]} in {csv_path}")
if not df["label"].isin([-1, 1]).all():
bad = df[~df["label"].isin([-1, 1])]["label"].unique()
raise ValueError(f"Unexpected labels {bad} in {csv_path}")
# Derive database name from parent folder (e.g., "training-a" → "a")
db_folder = csv_path.parent.name
db_key = db_folder.replace("training-", "")
df["database"] = db_key
df["wav_path"] = df["filename"].apply(
lambda f: str(csv_path.parent / f"{f}.wav")
)
return df
def build_master_labels(
training_dirs: Optional[dict] = None,
) -> pd.DataFrame:
"""Merge all REFERENCE.csv files into a single master DataFrame.
Args:
training_dirs: Dict mapping db_key → Path. Defaults to config.
Returns:
DataFrame with columns [filename, label, database, wav_path, class_idx, class_name].
"""
if training_dirs is None:
training_dirs = TRAINING_DIRS
frames = []
for key, dir_path in sorted(training_dirs.items()):
csv_path = dir_path / "REFERENCE.csv"
if not csv_path.exists():
logger.warning("No REFERENCE.csv in %s — skipping", dir_path)
continue
df = parse_reference_csv(csv_path)
frames.append(df)
logger.info("Database %s: %d recordings", key, len(df))
if not frames:
raise RuntimeError("No REFERENCE.csv files found in any training directory")
master = pd.concat(frames, ignore_index=True)
# Map labels to class indices
master["class_idx"] = master["label"].map(LABEL_MAP)
master["class_name"] = master["class_idx"].map(LABEL_NAMES)
return master
def validate_wav_files(master_df: pd.DataFrame) -> pd.DataFrame:
"""Check that all .wav files exist and are readable.
Args:
master_df: Master labels DataFrame with 'wav_path' column.
Returns:
DataFrame with added columns [exists, readable, duration, sr].
"""
exists_list = []
readable_list = []
duration_list = []
sr_list = []
for _, row in master_df.iterrows():
wav = Path(row["wav_path"])
file_exists = wav.exists()
exists_list.append(file_exists)
if file_exists:
try:
info = sf.info(str(wav))
readable_list.append(True)
duration_list.append(info.duration)
sr_list.append(info.samplerate)
except Exception as e:
logger.warning("Cannot read %s: %s", wav, e)
readable_list.append(False)
duration_list.append(0.0)
sr_list.append(0)
else:
readable_list.append(False)
duration_list.append(0.0)
sr_list.append(0)
master_df = master_df.copy()
master_df["exists"] = exists_list
master_df["readable"] = readable_list
master_df["duration"] = duration_list
master_df["sr"] = sr_list
return master_df
def get_dataset_stats(master_df: pd.DataFrame) -> dict:
"""Compute summary statistics of the dataset.
Args:
master_df: Validated master DataFrame.
Returns:
Dict with total, per-database counts, class distribution, duration stats.
"""
stats = {
"total_recordings": len(master_df),
"total_existing": int(master_df["exists"].sum()) if "exists" in master_df.columns else len(master_df),
"per_database": {},
"class_distribution": {},
"duration": {},
}
# Per-database breakdown
for db, group in master_df.groupby("database"):
normal_count = int((group["label"] == -1).sum())
abnormal_count = int((group["label"] == 1).sum())
stats["per_database"][db] = {
"total": len(group),
"normal": normal_count,
"abnormal": abnormal_count,
}
# Overall class distribution
stats["class_distribution"] = {
"normal": int((master_df["label"] == -1).sum()),
"abnormal": int((master_df["label"] == 1).sum()),
}
# Duration stats (if validated)
if "duration" in master_df.columns:
valid = master_df[master_df["readable"] == True]
if len(valid) > 0:
stats["duration"] = {
"min": float(valid["duration"].min()),
"max": float(valid["duration"].max()),
"mean": float(valid["duration"].mean()),
"median": float(valid["duration"].median()),
}
return stats
def save_master_labels(master_df: pd.DataFrame, output_path: Optional[Path] = None) -> Path:
"""Save master labels to CSV.
Args:
master_df: Master labels DataFrame.
output_path: Optional save path. Defaults to output/master_labels.csv.
Returns:
Path where file was saved.
"""
if output_path is None:
output_path = OUTPUT_DIR / "master_labels.csv"
output_path.parent.mkdir(parents=True, exist_ok=True)
master_df.to_csv(output_path, index=False)
logger.info("Saved master labels to %s", output_path)
return output_path
def run_phase1() -> pd.DataFrame:
"""Execute the full Phase 1 pipeline.
Returns:
Validated master labels DataFrame.
"""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger.info("=" * 60)
logger.info("PHASE 1 — Dataset Acquisition & Inventory")
logger.info("=" * 60)
# Build master labels
master = build_master_labels()
logger.info("Total recordings found: %d", len(master))
# Validate wav files
logger.info("Validating .wav files...")
master = validate_wav_files(master)
missing = len(master) - int(master["exists"].sum())
unreadable = int(master["exists"].sum()) - int(master["readable"].sum())
logger.info("Missing files: %d | Unreadable: %d", missing, unreadable)
# Stats
stats = get_dataset_stats(master)
logger.info("\n--- Dataset Summary ---")
logger.info("Total: %d | Existing: %d", stats["total_recordings"], stats["total_existing"])
for db, info in sorted(stats["per_database"].items()):
logger.info(
" Database %s: %d total (%d normal, %d abnormal)",
db, info["total"], info["normal"], info["abnormal"],
)
cd = stats["class_distribution"]
logger.info("Class distribution: %d normal, %d abnormal", cd["normal"], cd["abnormal"])
if stats["duration"]:
d = stats["duration"]
logger.info(
"Duration: min=%.1fs, max=%.1fs, mean=%.1fs, median=%.1fs",
d["min"], d["max"], d["mean"], d["median"],
)
# Save
save_path = save_master_labels(master)
logger.info("Master labels saved to: %s", save_path)
return master
if __name__ == "__main__":
run_phase1()