Download mtrcnn.py from jgalego/mosquito-mtrcnn-dg: direct link, hf CLI and curl.
- Browser
- Download file 60.9 kB
-
https://huggingface.co/jgalego/mosquito-mtrcnn-dg/resolve/main/mtrcnn.py
- Command line
-
hf download hf://jgalego/mosquito-mtrcnn-dg/mtrcnn.py
-
curl -L -o mtrcnn.py https://huggingface.co/jgalego/mosquito-mtrcnn-dg/resolve/main/mtrcnn.py
60.9 kB
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [ | |
| # "huggingface-hub", | |
| # "jinja2", | |
| # "numpy", | |
| # "scipy", | |
| # "soundfile", | |
| # "torch", | |
| # ] | |
| # /// | |
| """Train a compact domain-generalized mosquito species classifier.""" | |
| # pylint: disable=too-few-public-methods,too-many-arguments,too-many-instance-attributes | |
| # pylint: disable=too-many-lines,too-many-locals,too-many-positional-arguments | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import random | |
| import re | |
| import shutil | |
| import statistics | |
| import urllib.request | |
| import zipfile | |
| from collections import Counter | |
| from pathlib import Path | |
| import numpy as np | |
| import soundfile | |
| import torch | |
| from huggingface_hub import ( | |
| EvalResult, | |
| HfApi, | |
| ModelCard, | |
| ModelCardData, | |
| hf_hub_download, | |
| snapshot_download, | |
| ) | |
| from huggingface_hub.errors import RepositoryNotFoundError | |
| from scipy.signal import resample_poly | |
| from torch import nn | |
| from torch.nn import functional | |
| from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler | |
| DATASET = "aptemvs/mosquitoes-biodcase2026-task5" | |
| REPO = "jgalego/mosquito-mtrcnn-dg" | |
| ZENODO = "https://zenodo.org/api/records/20478577/files/Development_data.zip/content" | |
| SAMPLE_RATE = 8_000 | |
| N_FFT = 512 | |
| HOP_LENGTH = 80 | |
| N_MELS = 64 | |
| # Mains hum and handling noise sit below 200 Hz; wingbeat fundamentals are higher. | |
| MIN_HZ = 200 | |
| LEVEL_RMS = 0.05 | |
| # Every D5 clip lasts 0.625 s; longer crops would pad them with telltale silence. | |
| SECONDS = 0.625 | |
| SPECIES = [ | |
| "Aedes aegypti", | |
| "Aedes albopictus", | |
| "Culex quinquefasciatus", | |
| "Anopheles gambiae", | |
| "Anopheles arabiensis", | |
| "Anopheles dirus", | |
| "Culex pipiens", | |
| "Anopheles minimus", | |
| "Anopheles stephensi", | |
| ] | |
| DOMAINS = ["D1", "D2", "D3", "D4", "D5"] | |
| FILE_ID = re.compile(r"^S_(\d+)_D_(\d+)_(\d+)$") | |
| SYNTHETIC_DOMAINS = [ | |
| [0, 3, 4], | |
| [0, 2, 4], | |
| [3, 4], | |
| [4], | |
| [4], | |
| [0], | |
| [0, 1, 4], | |
| [0, 2, 3], | |
| [0, 1, 2], | |
| ] | |
| def parse_file_id(file_id): | |
| """Return zero-based species and domain indices from an official file ID.""" | |
| match = FILE_ID.fullmatch(file_id) | |
| if not match: | |
| raise ValueError(f"invalid BioDCASE file ID: {file_id}") | |
| species, domain, _ = map(int, match.groups()) | |
| if not (1 <= species <= len(SPECIES) and 1 <= domain <= len(DOMAINS)): | |
| raise ValueError(f"out-of-range BioDCASE file ID: {file_id}") | |
| return species - 1, domain - 1 | |
| def held_domain_split(ids, fold, seed=42, singleton_fraction=0.1): | |
| """Hold out a rare domain, or rows when only one domain exists, per species.""" | |
| groups = {} | |
| for file_id in ids: | |
| groups.setdefault(parse_file_id(file_id), []).append(file_id) | |
| held_domains = {} | |
| validation_ids = [] | |
| for species, species_name in enumerate(SPECIES): | |
| available = [domain for domain in range(len(DOMAINS)) if groups.get((species, domain))] | |
| candidates = sorted(available) | |
| if len(candidates) == 1: | |
| held_domains[species] = None | |
| rows = groups[species, candidates[0]].copy() | |
| random.Random(seed + species).shuffle(rows) | |
| validation_size = min( | |
| len(rows) - 1, | |
| max(1, round(singleton_fraction * len(rows))), | |
| ) | |
| validation_ids.extend(rows[:validation_size]) | |
| continue | |
| if fold >= len(candidates): | |
| raise ValueError( | |
| f"fold {fold} unavailable for {species_name}: " | |
| f"only {len(candidates)} domains" | |
| ) | |
| held_domains[species] = candidates[fold] | |
| validation_ids.extend(groups[species, candidates[fold]]) | |
| validation_set = set(validation_ids) | |
| training_ids = [file_id for file_id in ids if file_id not in validation_set] | |
| training_species = {parse_file_id(file_id)[0] for file_id in training_ids} | |
| validation_species = {parse_file_id(file_id)[0] for file_id in validation_ids} | |
| expected_species = set(range(len(SPECIES))) | |
| if training_species != expected_species or validation_species != expected_species: | |
| raise ValueError("held-domain split does not cover every species") | |
| return training_ids, validation_ids, held_domains | |
| def load_ids(split="Training"): | |
| """Download and read one official ID list.""" | |
| path = hf_hub_download(DATASET, f"data/metadata/{split}_ids.txt", repo_type="dataset") | |
| with open(path, encoding="utf-8") as handle: | |
| return [line.strip() for line in handle if line.strip()] | |
| def load_unseen_domains(): | |
| """Return the official unseen domain index for each species index.""" | |
| path = hf_hub_download(DATASET, "data/metadata/split_summary.json", repo_type="dataset") | |
| mapping = json.loads(Path(path).read_text(encoding="utf-8"))["unseen_domain_by_species"] | |
| return {SPECIES.index(species): DOMAINS.index(domain) for species, domain in mapping.items()} | |
| def download(url, path): | |
| """Download a large file atomically with coarse progress output.""" | |
| partial = path.with_suffix(path.suffix + ".part") | |
| with urllib.request.urlopen(url) as response, open(partial, "wb") as output: | |
| total = int(response.headers.get("Content-Length", 0)) | |
| downloaded = 0 | |
| report_at = 0 | |
| while chunk := response.read(1 << 20): | |
| output.write(chunk) | |
| downloaded += len(chunk) | |
| if downloaded >= report_at: | |
| print( | |
| f"downloaded {downloaded / 1e9:.1f}/{total / 1e9:.1f} GB", | |
| flush=True, | |
| ) | |
| report_at += 250_000_000 | |
| partial.rename(path) | |
| def prepare_data(data): | |
| """Download and flatten the official Zenodo development archive.""" | |
| destination = Path(data) | |
| marker = destination / ".complete" | |
| if marker.exists(): | |
| print(f"audio already prepared at {destination}") | |
| return | |
| destination.mkdir(parents=True, exist_ok=True) | |
| archive = destination.parent / "Development_data.zip" | |
| if not archive.exists(): | |
| download(ZENODO, archive) | |
| with zipfile.ZipFile(archive) as source: | |
| members = [ | |
| name | |
| for name in source.namelist() | |
| if name.lower().endswith(".wav") | |
| and not name.startswith("__MACOSX") | |
| and not Path(name).name.startswith("._") | |
| ] | |
| print(f"extracting {len(members)} waveforms", flush=True) | |
| for index, name in enumerate(members, 1): | |
| with source.open(name) as src, open( | |
| destination / Path(name).name, "wb" | |
| ) as dst: | |
| shutil.copyfileobj(src, dst) | |
| if index % 25_000 == 0: | |
| print(f"extracted {index}/{len(members)}", flush=True) | |
| marker.write_text(str(len(members)), encoding="utf-8") | |
| archive.unlink() | |
| print(f"prepared {len(members)} waveforms at {destination}") | |
| def select_ids(ids, size, seed): | |
| """Select a seeded species-domain-stratified subset.""" | |
| if not size or size >= len(ids): | |
| return ids | |
| groups = {} | |
| for file_id in ids: | |
| groups.setdefault(parse_file_id(file_id), []).append(file_id) | |
| rng = random.Random(seed) | |
| for rows in groups.values(): | |
| rng.shuffle(rows) | |
| selected = [] | |
| while len(selected) < size: | |
| added = False | |
| for key in sorted(groups): | |
| if groups[key] and len(selected) < size: | |
| selected.append(groups[key].pop()) | |
| added = True | |
| if not added: | |
| break | |
| rng.shuffle(selected) | |
| return selected | |
| def cpus(): | |
| """Return the CPU quota visible to this process.""" | |
| try: | |
| quota, period = Path("/sys/fs/cgroup/cpu.max").read_text( | |
| encoding="utf-8" | |
| ).split() | |
| if quota != "max": | |
| return max(1, int(quota) // int(period)) | |
| except OSError: | |
| pass | |
| return len(os.sched_getaffinity(0)) | |
| def load_waveform(path, seconds=None, training=False): | |
| """Read a mono waveform at the model sample rate, or only a random or central crop.""" | |
| start, frames = 0, -1 | |
| if seconds is not None: | |
| info = soundfile.info(path) | |
| frames = math.ceil(seconds * info.samplerate) | |
| gap = max(0, info.frames - frames) | |
| start = random.randrange(gap + 1) if training else gap // 2 | |
| waveform, sample_rate = soundfile.read( | |
| path, start=start, frames=frames, dtype="float32", always_2d=False | |
| ) | |
| if waveform.ndim > 1: | |
| waveform = waveform.mean(axis=1) | |
| if sample_rate != SAMPLE_RATE: | |
| waveform = resample_poly(waveform, SAMPLE_RATE, sample_rate).astype(np.float32) | |
| return waveform | |
| def normalize_level(waveforms, level_rms): | |
| """Scale each waveform to one RMS level so recording gain cannot reveal its domain.""" | |
| if level_rms <= 0: | |
| return waveforms.astype(np.float32) | |
| rms = np.sqrt(np.mean(np.square(waveforms), axis=-1, keepdims=True)) | |
| return (waveforms * (level_rms / np.maximum(rms, 1e-6))).astype(np.float32) | |
| def split_windows(waveform, samples): | |
| """Cover a complete clip with fixed-length windows, the last aligned to its end.""" | |
| starts = list(range(0, max(len(waveform) - samples + 1, 1), samples)) | |
| if starts[-1] + samples < len(waveform): | |
| starts.append(len(waveform) - samples) | |
| return np.stack( | |
| [fixed_length(waveform[start : start + samples], samples, False) for start in starts] | |
| ) | |
| def fixed_length(waveform, samples, training): | |
| """Randomly or centrally crop and zero-pad a waveform.""" | |
| if len(waveform) < samples: | |
| gap = samples - len(waveform) | |
| left = random.randrange(gap + 1) if training else gap // 2 | |
| waveform = np.pad(waveform, (left, gap - left)) | |
| if len(waveform) > samples: | |
| gap = len(waveform) - samples | |
| start = random.randrange(gap + 1) if training else gap // 2 | |
| waveform = waveform[start : start + samples] | |
| return waveform.astype(np.float32) | |
| class MosquitoDataset(Dataset): | |
| """Fold-filtered 8 kHz audio with species-domain sampling weights.""" | |
| def __init__( | |
| self, root, ids, seconds, training, balance_power, synthetic=False, level_rms=LEVEL_RMS | |
| ): | |
| self.root = Path(root) if root else None | |
| self.ids = ids | |
| self.samples = round(seconds * SAMPLE_RATE) | |
| self.training = training | |
| self.synthetic = synthetic | |
| self.level_rms = level_rms | |
| self.labels = [parse_file_id(file_id) for file_id in ids] | |
| counts = Counter(self.labels) | |
| self.sample_weights = [counts[label] ** -balance_power for label in self.labels] | |
| def __len__(self): | |
| return len(self.ids) | |
| def _synthetic_waveform(self, index): | |
| species, domain = self.labels[index] | |
| row = int(self.ids[index].rsplit("_", maxsplit=1)[1]) | |
| time = np.arange(self.samples, dtype=np.float32) / SAMPLE_RATE | |
| phase = (row % 7) * math.pi / 7 | |
| return 0.2 * np.sin(2 * np.pi * (180 + 35 * species + domain) * time + phase) | |
| def __getitem__(self, index): | |
| if self.synthetic: | |
| waveform = self._synthetic_waveform(index) | |
| else: | |
| waveform = load_waveform( | |
| self.root / f"{self.ids[index]}.wav", | |
| self.samples / SAMPLE_RATE, | |
| self.training, | |
| ) | |
| waveform = fixed_length(waveform, self.samples, self.training) | |
| waveform = normalize_level(waveform, self.level_rms) | |
| if self.training: | |
| waveform = np.roll(waveform, random.randrange(len(waveform))) | |
| waveform = waveform * 10 ** random.uniform(-0.3, 0.3) | |
| waveform = waveform + np.random.normal( | |
| 0, random.uniform(0, 0.005), len(waveform) | |
| ) | |
| species, domain = self.labels[index] | |
| return { | |
| "file_id": self.ids[index], | |
| "waveform": waveform.astype(np.float32), | |
| "species": species, | |
| "domain": domain, | |
| } | |
| class AudioCollator: | |
| """Stack fixed-length waveforms and labels.""" | |
| def __call__(self, rows): | |
| waveforms = torch.from_numpy(np.stack([row["waveform"] for row in rows])) | |
| return { | |
| "file_ids": [row["file_id"] for row in rows], | |
| "waveforms": waveforms, | |
| "lengths": torch.full((len(rows),), waveforms.size(1), dtype=torch.long), | |
| "species": torch.tensor([row["species"] for row in rows]), | |
| "domains": torch.tensor([row["domain"] for row in rows]), | |
| } | |
| class ClipDataset(MosquitoDataset): | |
| """Complete clips split into training-length evaluation windows.""" | |
| def __getitem__(self, index): | |
| if self.synthetic: | |
| waveform = self._synthetic_waveform(index) | |
| else: | |
| waveform = load_waveform(self.root / f"{self.ids[index]}.wav") | |
| species, domain = self.labels[index] | |
| return { | |
| "windows": normalize_level(split_windows(waveform, self.samples), self.level_rms), | |
| "species": species, | |
| "domain": domain, | |
| } | |
| class WindowCollator: | |
| """Concatenate every clip's windows and record which clip owns each.""" | |
| def __call__(self, rows): | |
| counts = torch.tensor([len(row["windows"]) for row in rows]) | |
| waveforms = torch.from_numpy(np.concatenate([row["windows"] for row in rows])) | |
| return { | |
| "waveforms": waveforms, | |
| "lengths": torch.full((len(waveforms),), waveforms.size(1), dtype=torch.long), | |
| "owners": torch.repeat_interleave(torch.arange(len(rows)), counts), | |
| "species": torch.tensor([row["species"] for row in rows]), | |
| "domains": torch.tensor([row["domain"] for row in rows]), | |
| } | |
| def synthetic_ids(rows_per_cell): | |
| """Build official-shaped IDs with the real species-domain availability pattern.""" | |
| return [ | |
| f"S_{species + 1}_D_{domain + 1}_{row + 1}" | |
| for species, domains in enumerate(SYNTHETIC_DOMAINS) | |
| for domain in domains | |
| for row in range(rows_per_cell) | |
| ] | |
| def make_datasets(args): | |
| """Create one pseudo-unseen fold, or for fold -1 all training rows and the official | |
| validation split.""" | |
| ids = synthetic_ids(args.synthetic_rows) if args.synthetic else load_ids() | |
| if args.fold < 0: | |
| training_ids = ids | |
| validation_ids = ids if args.synthetic else load_ids("Validation") | |
| held_domains = dict.fromkeys(range(len(SPECIES))) | |
| else: | |
| training_ids, validation_ids, held_domains = held_domain_split( | |
| ids, args.fold, seed=args.seed | |
| ) | |
| training_ids = select_ids(training_ids, args.train_size, args.seed) | |
| validation_ids = select_ids(validation_ids, args.validation_size, args.seed) | |
| training = MosquitoDataset( | |
| args.data, | |
| training_ids, | |
| args.seconds, | |
| True, | |
| args.balance_power, | |
| args.synthetic, | |
| args.level_rms, | |
| ) | |
| validation = MosquitoDataset( | |
| args.data, | |
| validation_ids, | |
| args.seconds, | |
| False, | |
| args.balance_power, | |
| args.synthetic, | |
| args.level_rms, | |
| ) | |
| expected = set(range(len(SPECIES))) | |
| if {label[0] for label in training.labels} != expected: | |
| raise ValueError("training size limit removed a species") | |
| if {label[0] for label in validation.labels} != expected: | |
| raise ValueError("validation size limit removed a species") | |
| return training, validation, held_domains | |
| def seed_worker(_worker_id): | |
| """Seed NumPy and Python from a DataLoader worker's PyTorch seed.""" | |
| worker_seed = torch.initial_seed() % (2**32) | |
| random.seed(worker_seed) | |
| np.random.seed(worker_seed) | |
| def make_loader(dataset, batch_size, workers, training, seed, collator=None): | |
| """Create a deterministic loader, balanced by species-domain cell for training.""" | |
| generator = torch.Generator().manual_seed(seed) | |
| sampler = None | |
| if training: | |
| sampler = WeightedRandomSampler( | |
| dataset.sample_weights, | |
| num_samples=len(dataset), | |
| replacement=True, | |
| generator=generator, | |
| ) | |
| return DataLoader( | |
| dataset, | |
| batch_size=batch_size, | |
| sampler=sampler, | |
| shuffle=False, | |
| num_workers=workers, | |
| pin_memory=torch.cuda.is_available(), | |
| persistent_workers=workers > 0, | |
| worker_init_fn=seed_worker, | |
| generator=generator, | |
| collate_fn=collator or AudioCollator(), | |
| ) | |
| def deterministic_view(dataset, ids=None): | |
| """Return an unaugmented view over a dataset's IDs.""" | |
| return MosquitoDataset( | |
| dataset.root, | |
| dataset.ids if ids is None else ids, | |
| dataset.samples / SAMPLE_RATE, | |
| training=False, | |
| balance_power=0.0, | |
| synthetic=dataset.synthetic, | |
| level_rms=dataset.level_rms, | |
| ) | |
| def compute_normalization(dataset, size, batch_size, workers, device, seed, min_hz, lifter=0): | |
| """Estimate per-mel mean and variance from fold-training rows only.""" | |
| ids = select_ids(dataset.ids, size, seed) | |
| loader = make_loader( | |
| deterministic_view(dataset, ids), batch_size, workers, False, seed | |
| ) | |
| frontend = LogMelFrontend(min_hz=min_hz, lifter=lifter).to(device) | |
| total = torch.zeros(N_MELS, dtype=torch.float64, device=device) | |
| squared = torch.zeros_like(total) | |
| frames = 0 | |
| for batch in loader: | |
| waveforms = batch["waveforms"].to(device, non_blocking=True) | |
| lengths = batch["lengths"].to(device, non_blocking=True) | |
| features, frame_lengths = frontend(waveforms, lengths) | |
| positions = torch.arange(features.size(1), device=device)[None, :] | |
| mask = positions < frame_lengths[:, None] | |
| selected = features[mask].double() | |
| total += selected.sum(0) | |
| squared += selected.square().sum(0) | |
| frames += selected.size(0) | |
| mean = total / frames | |
| variance = squared / frames - mean.square() | |
| return mean.float().cpu(), variance.clamp_min(1e-6).sqrt().float().cpu() | |
| def balanced_accuracy(gold, predicted): | |
| """Return mean per-species recall over labels present in gold.""" | |
| gold = np.asarray(gold) | |
| predicted = np.asarray(predicted) | |
| recalls = [ | |
| np.mean(predicted[gold == label] == label) | |
| for label in sorted(set(gold.tolist())) | |
| ] | |
| return float(np.mean(recalls)) if recalls else 0.0 | |
| def prediction_metrics(labels, predictions): | |
| """Return balanced accuracy, per-species recall and a gold-by-predicted confusion.""" | |
| labels = labels.cpu().numpy() | |
| predictions = predictions.cpu().numpy() | |
| confusion = np.bincount( | |
| labels * len(SPECIES) + predictions, minlength=len(SPECIES) ** 2 | |
| ).reshape(len(SPECIES), len(SPECIES)) | |
| return { | |
| "balanced_accuracy": balanced_accuracy(labels, predictions), | |
| "rows": len(labels), | |
| "per_species": { | |
| SPECIES[label]: float(np.mean(predictions[labels == label] == label)) | |
| for label in sorted(set(labels.tolist())) | |
| }, | |
| "confusion": confusion.tolist(), | |
| } | |
| def official_metrics(labels, predictions, domains, unseen_domains): | |
| """Score the BioDCASE seen and unseen species-domain partitions.""" | |
| unseen = torch.tensor( | |
| [ | |
| unseen_domains[label.item()] == domain.item() | |
| for label, domain in zip(labels, domains) | |
| ], | |
| dtype=torch.bool, | |
| ) | |
| return { | |
| "seen": prediction_metrics(labels[~unseen], predictions[~unseen]), | |
| "unseen": prediction_metrics(labels[unseen], predictions[unseen]), | |
| } | |
| def fold_prediction_metrics(labels, predictions, domains, held_domains): | |
| """Split fold metrics into true domain holdouts and singleton reserves.""" | |
| metrics = prediction_metrics(labels, predictions) | |
| domain_holdout = torch.tensor( | |
| [held_domains[label.item()] is not None for label in labels], | |
| dtype=torch.bool, | |
| ) | |
| metrics["domain_holdout"] = prediction_metrics( | |
| labels[domain_holdout], predictions[domain_holdout] | |
| ) | |
| metrics["same_domain"] = prediction_metrics( | |
| labels[~domain_holdout], predictions[~domain_holdout] | |
| ) | |
| expected_domains = torch.tensor( | |
| [ | |
| held_domains[label.item()] | |
| if held_domains[label.item()] is not None | |
| else domain.item() | |
| for label, domain in zip(labels, domains) | |
| ] | |
| ) | |
| if not torch.equal(domains, expected_domains): | |
| raise ValueError("validation rows do not match the declared fold policy") | |
| return metrics | |
| def collect_outputs(model, frontend, dataset, batch_size, workers, device, seed): | |
| """Collect deterministic logits and embeddings for one dataset.""" | |
| model.eval() | |
| loader = make_loader(dataset, batch_size, workers, False, seed) | |
| collected = {"labels": [], "domains": [], "logits": [], "embeddings": []} | |
| for batch in loader: | |
| waveforms = batch["waveforms"].to(device, non_blocking=True) | |
| lengths = batch["lengths"].to(device, non_blocking=True) | |
| domains = batch["domains"].to(device, non_blocking=True) | |
| features, frame_lengths = frontend(waveforms, lengths) | |
| outputs = model(features, frame_lengths, domains) | |
| collected["labels"].append(batch["species"]) | |
| collected["domains"].append(batch["domains"]) | |
| collected["logits"].append(outputs["species_logits"].cpu()) | |
| collected["embeddings"].append(outputs["embedding"].cpu()) | |
| return {name: torch.cat(values) for name, values in collected.items()} | |
| def collect_clip_outputs(model, frontend, dataset, batch_size, workers, device, seed): | |
| """Average window probabilities and embeddings over each complete clip.""" | |
| model.eval() | |
| loader = make_loader(dataset, batch_size, workers, False, seed, WindowCollator()) | |
| collected = {"labels": [], "domains": [], "probabilities": [], "embeddings": []} | |
| for batch in loader: | |
| owners = batch["owners"].to(device, non_blocking=True) | |
| domains = batch["domains"].to(device, non_blocking=True) | |
| window_outputs = {"probabilities": [], "embeddings": []} | |
| for start in range(0, len(owners), batch_size): | |
| window = slice(start, start + batch_size) | |
| features, frame_lengths = frontend( | |
| batch["waveforms"][window].to(device, non_blocking=True), | |
| batch["lengths"][window].to(device, non_blocking=True), | |
| ) | |
| outputs = model(features, frame_lengths, domains[owners[window]]) | |
| window_outputs["probabilities"].append( | |
| functional.softmax(outputs["species_logits"], dim=1) | |
| ) | |
| window_outputs["embeddings"].append(outputs["embedding"]) | |
| counts = torch.bincount(owners, minlength=len(domains)).unsqueeze(1) | |
| for name, values in window_outputs.items(): | |
| values = torch.cat(values) | |
| totals = values.new_zeros(len(domains), values.size(1)) | |
| collected[name].append((totals.index_add_(0, owners, values) / counts).cpu()) | |
| collected["labels"].append(batch["species"]) | |
| collected["domains"].append(batch["domains"]) | |
| return {name: torch.cat(values) for name, values in collected.items()} | |
| def mahalanobis_fit(reference_embeddings, reference_labels): | |
| """Return class means and the precision of one regularized shared covariance matrix.""" | |
| if set(reference_labels.tolist()) != set(range(len(SPECIES))): | |
| raise ValueError("Mahalanobis reference data must cover every species") | |
| means = torch.stack( | |
| [ | |
| reference_embeddings[reference_labels == label].mean(0) | |
| for label in range(len(SPECIES)) | |
| ] | |
| ) | |
| residuals = reference_embeddings - means[reference_labels] | |
| covariance = residuals.T @ residuals / max(1, len(residuals) - len(SPECIES)) | |
| regularization = covariance.diagonal().mean().clamp_min(1e-6) * 1e-3 | |
| covariance = covariance + regularization * torch.eye(covariance.size(0)) | |
| return means, torch.linalg.pinv(covariance) | |
| def mahalanobis_predict(means, precision, embeddings): | |
| """Assign each embedding to the nearest class mean in Mahalanobis distance.""" | |
| differences = embeddings[:, None, :] - means[None, :, :] | |
| distances = torch.einsum("ncd,de,nce->nc", differences, precision, differences) | |
| return distances.argmin(1) | |
| def sampled_priors(dataset, device): | |
| """Return species priors under the balanced training sampler for logit adjustment.""" | |
| totals = torch.zeros(len(SPECIES), dtype=torch.float64).index_add_( | |
| 0, | |
| torch.tensor([species for species, _domain in dataset.labels]), | |
| torch.tensor(dataset.sample_weights, dtype=torch.float64), | |
| ) | |
| return (totals / totals.sum()).float().to(device) | |
| def grl_strength(step, total_steps): | |
| """Return the standard sigmoid gradient-reversal warmup.""" | |
| progress = step / max(1, total_steps - 1) | |
| return 2 / (1 + math.exp(-10 * progress)) - 1 | |
| def learning_rate_schedule(total_steps, warmup_ratio): | |
| """Return a linear-warmup cosine-decay multiplier.""" | |
| warmup_steps = max(1, round(total_steps * warmup_ratio)) | |
| def multiplier(step): | |
| if step < warmup_steps: | |
| return (step + 1) / warmup_steps | |
| progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) | |
| return 0.5 * (1 + math.cos(math.pi * min(1.0, progress))) | |
| return multiplier | |
| def train_epoch(model, frontend, loader, optimizer, scheduler, priors, epoch, state): | |
| """Run one balanced training epoch or stop at the configured step limit.""" | |
| model.train() | |
| totals = {} | |
| batches = 0 | |
| for batch in loader: | |
| if 0 < state["max_steps"] <= state["step"]: | |
| break | |
| waveforms = batch["waveforms"].to(state["device"], non_blocking=True) | |
| lengths = batch["lengths"].to(state["device"], non_blocking=True) | |
| species = batch["species"].to(state["device"], non_blocking=True) | |
| domains = batch["domains"].to(state["device"], non_blocking=True) | |
| with torch.no_grad(): | |
| features, frame_lengths = frontend(waveforms, lengths) | |
| optimizer.zero_grad(set_to_none=True) | |
| outputs = model( | |
| features, | |
| frame_lengths, | |
| domains, | |
| domain_strength=grl_strength(state["step"], state["total_steps"]), | |
| augment=True, | |
| ) | |
| losses = training_loss(outputs, species, domains, priors, epoch + 1) | |
| losses["loss"].backward() | |
| nn.utils.clip_grad_norm_(model.parameters(), state["gradient_clip"]) | |
| optimizer.step() | |
| scheduler.step() | |
| state["step"] += 1 | |
| batches += 1 | |
| for name, value in losses.items(): | |
| totals[name] = totals.get(name, 0.0) + value.detach().item() | |
| if not batches: | |
| return None | |
| return {name: value / batches for name, value in totals.items()} | |
| def checkpoint_state(model, frontend, optimizer, scheduler, epoch, state, metrics, args): | |
| """Build a resumable best-checkpoint payload.""" | |
| return { | |
| "model": model.state_dict(), | |
| "frontend": frontend.state_dict(), | |
| "optimizer": optimizer.state_dict(), | |
| "scheduler": scheduler.state_dict(), | |
| "epoch": epoch, | |
| "step": state["step"], | |
| "metrics": metrics, | |
| "arguments": {key: value for key, value in vars(args).items() if key != "run"}, | |
| } | |
| def final_metrics(model, frontend, training, validation, held_domains, args, device): | |
| """Score softmax and shared-covariance Mahalanobis inference.""" | |
| reference_ids = select_ids(training.ids, args.reference_size, args.seed + 1) | |
| reference = deterministic_view(training, reference_ids) | |
| reference_outputs = collect_outputs( | |
| model, | |
| frontend, | |
| reference, | |
| args.eval_batch_size, | |
| args.workers, | |
| device, | |
| args.seed, | |
| ) | |
| validation_outputs = collect_outputs( | |
| model, | |
| frontend, | |
| validation, | |
| args.eval_batch_size, | |
| args.workers, | |
| device, | |
| args.seed, | |
| ) | |
| softmax = fold_prediction_metrics( | |
| validation_outputs["labels"], | |
| validation_outputs["logits"].argmax(1), | |
| validation_outputs["domains"], | |
| held_domains, | |
| ) | |
| mahalanobis = fold_prediction_metrics( | |
| validation_outputs["labels"], | |
| mahalanobis_predict( | |
| *mahalanobis_fit(reference_outputs["embeddings"], reference_outputs["labels"]), | |
| validation_outputs["embeddings"], | |
| ), | |
| validation_outputs["domains"], | |
| held_domains, | |
| ) | |
| return softmax, mahalanobis, reference_outputs | |
| def train(args): | |
| """Train and select MTRCNN-DG on one training-only pseudo-unseen fold.""" | |
| set_seed(args.seed) | |
| if not args.synthetic and not (Path(args.data) / ".complete").exists(): | |
| prepare_data(args.data) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| training, validation, held_domains = make_datasets(args) | |
| mean, std = compute_normalization( | |
| training, | |
| args.stats_size, | |
| args.eval_batch_size, | |
| args.workers, | |
| device, | |
| args.seed, | |
| args.min_hz, | |
| args.lifter, | |
| ) | |
| frontend = LogMelFrontend(mean, std, args.min_hz, args.lifter).to(device) | |
| model = MTRCNNDG(dropout=args.dropout).to(device) | |
| loader = make_loader( | |
| training, args.batch_size, args.workers, True, args.seed | |
| ) | |
| natural_steps = args.epochs * len(loader) | |
| total_steps = min(natural_steps, args.max_steps) if args.max_steps > 0 else natural_steps | |
| optimizer = torch.optim.AdamW( | |
| model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay | |
| ) | |
| scheduler = torch.optim.lr_scheduler.LambdaLR( | |
| optimizer, learning_rate_schedule(total_steps, args.warmup_ratio) | |
| ) | |
| priors = sampled_priors(training, device) | |
| output = Path(args.output) | |
| output.mkdir(parents=True, exist_ok=True) | |
| checkpoint = output / "checkpoint.pt" | |
| state = { | |
| "device": device, | |
| "step": 0, | |
| "total_steps": total_steps, | |
| "max_steps": args.max_steps, | |
| "gradient_clip": args.gradient_clip, | |
| } | |
| history = [] | |
| best_score = -1.0 | |
| best_epoch = 0 | |
| stale_epochs = 0 | |
| for epoch in range(args.epochs): | |
| losses = train_epoch( | |
| model, frontend, loader, optimizer, scheduler, priors, epoch, state | |
| ) | |
| if losses is None: | |
| break | |
| validation_outputs = collect_outputs( | |
| model, | |
| frontend, | |
| validation, | |
| args.eval_batch_size, | |
| args.workers, | |
| device, | |
| args.seed, | |
| ) | |
| metrics = fold_prediction_metrics( | |
| validation_outputs["labels"], | |
| validation_outputs["logits"].argmax(1), | |
| validation_outputs["domains"], | |
| held_domains, | |
| ) | |
| record = {"epoch": epoch + 1, "step": state["step"], "losses": losses, **metrics} | |
| history.append(record) | |
| print(json.dumps(record), flush=True) | |
| # Without a held-out domain (fold -1) the last epoch is kept. | |
| score = metrics["domain_holdout"]["balanced_accuracy"] if args.fold >= 0 else epoch | |
| if score > best_score: | |
| best_score = score | |
| best_epoch = epoch + 1 | |
| stale_epochs = 0 | |
| torch.save( | |
| checkpoint_state( | |
| model, | |
| frontend, | |
| optimizer, | |
| scheduler, | |
| epoch + 1, | |
| state, | |
| metrics, | |
| args, | |
| ), | |
| checkpoint, | |
| ) | |
| else: | |
| stale_epochs += 1 | |
| reached_limit = 0 < args.max_steps <= state["step"] | |
| if reached_limit or stale_epochs >= args.patience: | |
| break | |
| saved = torch.load(checkpoint, map_location=device, weights_only=True) | |
| model.load_state_dict(saved["model"]) | |
| frontend.load_state_dict(saved["frontend"]) | |
| softmax, mahalanobis, reference = final_metrics( | |
| model, frontend, training, validation, held_domains, args, device | |
| ) | |
| result = { | |
| "device": str(device), | |
| "fold": args.fold, | |
| "seed": args.seed, | |
| "training_rows": len(training), | |
| "validation_rows": len(validation), | |
| "normalization_rows": len(select_ids(training.ids, args.stats_size, args.seed)), | |
| "reference_rows": len(reference["labels"]), | |
| "held_domains": { | |
| SPECIES[species]: DOMAINS[domain] if domain is not None else "same-domain" | |
| for species, domain in held_domains.items() | |
| }, | |
| "best_epoch": best_epoch, | |
| "steps": state["step"], | |
| "softmax": softmax, | |
| "mahalanobis": mahalanobis, | |
| "history": history, | |
| "checkpoint": str(checkpoint), | |
| "test": test_metrics(model, frontend, reference, args, device) if args.test else None, | |
| } | |
| (output / "results.json").write_text( | |
| json.dumps(result, indent=1), encoding="utf-8" | |
| ) | |
| print(json.dumps(result, indent=1)) | |
| def test_metrics(model, frontend, reference, args, device): | |
| """Score the official development test split with softmax and Mahalanobis inference.""" | |
| test_ids = ( | |
| [ | |
| f"S_{species + 1}_D_{domain + 1}_{row + 1}" | |
| for species in range(len(SPECIES)) | |
| for domain in range(len(DOMAINS)) | |
| for row in range(args.synthetic_rows) | |
| ] | |
| if args.synthetic | |
| else load_ids("Test") | |
| ) | |
| test = ClipDataset( | |
| args.data, | |
| select_ids(test_ids, args.test_size, args.seed), | |
| args.seconds, | |
| False, | |
| 0.0, | |
| args.synthetic, | |
| args.level_rms, | |
| ) | |
| outputs = collect_clip_outputs( | |
| model, frontend, test, args.eval_batch_size, args.workers, device, args.seed | |
| ) | |
| unseen_domains = load_unseen_domains() | |
| mahalanobis = mahalanobis_predict( | |
| *mahalanobis_fit(reference["embeddings"], reference["labels"]), outputs["embeddings"] | |
| ) | |
| return { | |
| "rows": len(test), | |
| "softmax": official_metrics( | |
| outputs["labels"], | |
| outputs["probabilities"].argmax(1), | |
| outputs["domains"], | |
| unseen_domains, | |
| ), | |
| "mahalanobis": official_metrics( | |
| outputs["labels"], mahalanobis, outputs["domains"], unseen_domains | |
| ), | |
| } | |
| def evaluate(args): | |
| """Score a checkpoint on its validation rows and on the official development test split.""" | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| saved = torch.load(args.checkpoint, map_location=device, weights_only=True) | |
| options = argparse.Namespace(**{"lifter": 0, **saved["arguments"]}) | |
| options.data = args.data | |
| options.eval_batch_size = args.eval_batch_size | |
| options.workers = args.workers | |
| options.test_size = args.test_size | |
| set_seed(options.seed) | |
| if not options.synthetic and not (Path(args.data) / ".complete").exists(): | |
| prepare_data(args.data) | |
| training, validation, held_domains = make_datasets(options) | |
| frontend = LogMelFrontend(min_hz=options.min_hz, lifter=options.lifter).to(device) | |
| frontend.load_state_dict(saved["frontend"]) | |
| model = MTRCNNDG(dropout=options.dropout).to(device) | |
| model.load_state_dict(saved["model"]) | |
| fold_softmax, fold_mahalanobis, reference = final_metrics( | |
| model, frontend, training, validation, held_domains, options, device | |
| ) | |
| result = { | |
| "checkpoint": args.checkpoint, | |
| "epoch": saved["epoch"], | |
| "fold": options.fold, | |
| "seed": options.seed, | |
| "arguments": saved["arguments"], | |
| "test": test_metrics(model, frontend, reference, options, device), | |
| "fold_softmax": fold_softmax, | |
| "fold_mahalanobis": fold_mahalanobis, | |
| } | |
| output = Path(args.output) | |
| output.parent.mkdir(parents=True, exist_ok=True) | |
| output.write_text(json.dumps(result, indent=1), encoding="utf-8") | |
| print(json.dumps(result, indent=1)) | |
| if args.export: | |
| export(model, frontend, reference, saved["arguments"], result, Path(args.export)) | |
| if args.repo: | |
| HfApi().upload_folder( | |
| repo_id=args.repo, | |
| folder_path=args.export, | |
| commit_message="Add MTRCNN-DG weights and evaluation", | |
| ) | |
| def export(model, frontend, reference, arguments, result, folder): | |
| """Write weights, Mahalanobis statistics, this script and the evaluation to folder.""" | |
| means, precision = mahalanobis_fit(reference["embeddings"], reference["labels"]) | |
| (folder / "results").mkdir(parents=True, exist_ok=True) | |
| torch.save( | |
| { | |
| "model": model.state_dict(), | |
| "frontend": frontend.state_dict(), | |
| "arguments": arguments, | |
| "means": means, | |
| "precision": precision, | |
| }, | |
| folder / "model.pt", | |
| ) | |
| shutil.copy(__file__, folder / "mtrcnn.py") | |
| (folder / "results" / "eval.json").write_text(json.dumps(result, indent=1), encoding="utf-8") | |
| def predict(args): | |
| """Print the Mahalanobis species prediction for each WAV file.""" | |
| path = Path(args.model) | |
| if not path.is_file(): | |
| path = hf_hub_download(args.model, "model.pt") | |
| saved = torch.load(path, map_location="cpu", weights_only=True) | |
| options = saved["arguments"] | |
| frontend = LogMelFrontend(min_hz=options["min_hz"], lifter=options.get("lifter", 0)) | |
| frontend.load_state_dict(saved["frontend"]) | |
| model = MTRCNNDG().eval() | |
| model.load_state_dict(saved["model"]) | |
| samples = round(options["seconds"] * SAMPLE_RATE) | |
| for file in args.files: | |
| windows = split_windows(load_waveform(file), samples) | |
| waveforms = torch.from_numpy(normalize_level(windows, options["level_rms"])) | |
| with torch.no_grad(): | |
| features, lengths = frontend(waveforms, torch.full((len(waveforms),), samples)) | |
| embedding = model(features, lengths, torch.zeros(len(waveforms), dtype=torch.long))[ | |
| "embedding" | |
| ].mean(0, keepdim=True) | |
| species = mahalanobis_predict(saved["means"], saved["precision"], embedding).item() | |
| print(json.dumps({"file": file, "species": SPECIES[species]})) | |
| def card(args): | |
| """Render card.jinja into card/README.md with the results stored in the model repo.""" | |
| try: | |
| folder = Path(snapshot_download(args.repo, allow_patterns="results/*.json")) | |
| paths = folder.glob("results/*.json") | |
| except RepositoryNotFoundError: | |
| paths = [] | |
| results = {path.stem: json.loads(path.read_text(encoding="utf-8")) for path in paths} | |
| runs = {} | |
| for name, result in sorted(results.items()): | |
| if name.startswith("train-"): | |
| runs.setdefault(name.split("-")[1], []).append(result["test"]) | |
| seeds = { | |
| config: { | |
| inference: { | |
| partition: [run[inference][partition]["balanced_accuracy"] * 100 for run in tests] | |
| for partition in ("seen", "unseen") | |
| } | |
| for inference in ("softmax", "mahalanobis") | |
| } | |
| for config, tests in runs.items() | |
| } | |
| evaluation = results.get("eval") | |
| data = ModelCardData( | |
| model_name=args.repo.split("/")[1], | |
| datasets=[DATASET], | |
| license="cc-by-4.0", | |
| tags=["audio-classification", "bioacoustics", "mosquito", "domain-generalization"], | |
| eval_results=[ | |
| EvalResult( | |
| task_type="audio-classification", | |
| dataset_type=DATASET, | |
| dataset_name="BioDCASE 2026 Task 5 development test, unseen domains", | |
| metric_type="balanced_accuracy", | |
| metric_name="Unseen-domain balanced accuracy", | |
| metric_value=round( | |
| evaluation["test"]["mahalanobis"]["unseen"]["balanced_accuracy"] * 100, 2 | |
| ), | |
| ) | |
| ] | |
| if evaluation | |
| else None, | |
| ) | |
| rendered = ModelCard.from_template( | |
| data, | |
| template_path=Path(__file__).parent / "card.jinja", | |
| repo=args.repo, | |
| dataset=DATASET, | |
| species=SPECIES, | |
| evaluation=evaluation, | |
| seeds=seeds, | |
| mean=statistics.mean, | |
| stdev=statistics.stdev, | |
| ) | |
| output = Path(__file__).parent / "card" | |
| output.mkdir(exist_ok=True) | |
| rendered.save(output / "README.md") | |
| def set_seed(seed): | |
| """Seed Python, NumPy and PyTorch.""" | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| torch.manual_seed(seed) | |
| def mel_filter_bank(min_hz): | |
| """Return a triangular Slaney-style mel filter bank from min_hz to Nyquist.""" | |
| frequencies = torch.linspace(0, SAMPLE_RATE / 2, N_FFT // 2 + 1) | |
| mel_min = 2595 * math.log10(1 + min_hz / 700) | |
| mel_max = 2595 * math.log10(1 + (SAMPLE_RATE / 2) / 700) | |
| mel_points = torch.linspace(mel_min, mel_max, N_MELS + 2) | |
| hz_points = 700 * (torch.pow(10, mel_points / 2595) - 1) | |
| lower = (frequencies[:, None] - hz_points[:-2]) / ( | |
| hz_points[1:-1] - hz_points[:-2] | |
| ) | |
| upper = (hz_points[2:] - frequencies[:, None]) / ( | |
| hz_points[2:] - hz_points[1:-1] | |
| ) | |
| return torch.minimum(lower, upper).clamp_min(0) | |
| def lifter_projection(lifter): | |
| """Return the matrix that removes each log-mel frame's lifter smoothest DCT components. | |
| A recording device colours the spectrum with a smooth gain curve, which lands in these | |
| components; the wingbeat harmonics vary faster across mel bins and survive. | |
| """ | |
| bins = torch.arange(N_MELS) | |
| basis = torch.cos(math.pi * torch.arange(lifter)[:, None] * (bins + 0.5) / N_MELS) | |
| basis = basis / basis.norm(dim=1, keepdim=True) | |
| return torch.eye(N_MELS) - basis.T @ basis | |
| class LogMelFrontend(nn.Module): | |
| """Fixed 8 kHz, 64-bin log-mel frontend with optional liftering and global normalization.""" | |
| def __init__(self, mean=None, std=None, min_hz=MIN_HZ, lifter=0): | |
| super().__init__() | |
| self.register_buffer("window", torch.hann_window(N_FFT), persistent=False) | |
| self.register_buffer("mel_filters", mel_filter_bank(min_hz), persistent=False) | |
| self.register_buffer("projection", lifter_projection(lifter), persistent=False) | |
| self.register_buffer( | |
| "mean", | |
| torch.zeros(N_MELS) if mean is None else torch.as_tensor(mean).float(), | |
| ) | |
| self.register_buffer( | |
| "std", | |
| torch.ones(N_MELS) if std is None else torch.as_tensor(std).float(), | |
| ) | |
| def forward(self, waveforms, sample_lengths): | |
| """Convert padded waveforms to normalized log-mel frames.""" | |
| spectrum = torch.stft( | |
| waveforms, | |
| n_fft=N_FFT, | |
| hop_length=HOP_LENGTH, | |
| win_length=N_FFT, | |
| window=self.window, | |
| center=True, | |
| pad_mode="reflect", | |
| return_complex=True, | |
| ) | |
| power = spectrum.abs().square().transpose(1, 2) | |
| features = 10 * torch.log10((power @ self.mel_filters).clamp_min(1e-10)) | |
| features = features @ self.projection | |
| features = (features - self.mean) / self.std.clamp_min(1e-6) | |
| frame_lengths = torch.div(sample_lengths, HOP_LENGTH, rounding_mode="floor") + 1 | |
| return features, frame_lengths | |
| def different_domain_partners(domains): | |
| """Choose a random different-domain partner for every available sample.""" | |
| partners = [] | |
| for index, domain in enumerate(domains): | |
| candidates = torch.nonzero(domains != domain, as_tuple=False).flatten() | |
| if candidates.numel(): | |
| choice = torch.randint(candidates.numel(), (), device=domains.device) | |
| partners.append(candidates[choice]) | |
| else: | |
| partners.append(torch.tensor(index, device=domains.device)) | |
| return torch.stack(partners) | |
| class FourierMix(nn.Module): | |
| """Mix log-mel Fourier amplitudes between recording domains.""" | |
| def __init__(self, probability=0.5): | |
| super().__init__() | |
| self.probability = probability | |
| def forward(self, features, domains): | |
| """Optionally replace each sample's amplitude style across domains.""" | |
| if not self.training or torch.rand((), device=features.device) >= self.probability: | |
| return features | |
| partners = different_domain_partners(domains) | |
| spectrum = torch.fft.rfft2(features, dim=(-2, -1), norm="ortho") | |
| amplitude = spectrum.abs() | |
| phase = spectrum / amplitude.clamp_min(1e-8) | |
| weight = torch.rand(features.size(0), 1, 1, device=features.device) | |
| mixed_amplitude = (1 - weight) * amplitude + weight * amplitude[partners] | |
| return torch.fft.irfft2( | |
| mixed_amplitude * phase, | |
| s=features.shape[-2:], | |
| dim=(-2, -1), | |
| norm="ortho", | |
| ) | |
| class MixStyle(nn.Module): | |
| """Mix channel statistics between domains after a convolutional stage.""" | |
| def __init__(self, probability=0.5, alpha=0.1): | |
| super().__init__() | |
| self.probability = probability | |
| self.beta = torch.distributions.Beta(alpha, alpha) | |
| def forward(self, features, domains): | |
| """Optionally mix feature statistics across recording domains.""" | |
| if not self.training or torch.rand((), device=features.device) >= self.probability: | |
| return features | |
| mean = features.mean(dim=(2, 3), keepdim=True).detach() | |
| std = (features.var(dim=(2, 3), keepdim=True, unbiased=False) + 1e-6).sqrt().detach() | |
| normalized = (features - mean) / std | |
| partners = different_domain_partners(domains) | |
| weight = self.beta.sample((features.size(0), 1, 1, 1)).to(features.device) | |
| mixed_mean = weight * mean + (1 - weight) * mean[partners] | |
| mixed_std = weight * std + (1 - weight) * std[partners] | |
| return normalized * mixed_std + mixed_mean | |
| class ConvStage(nn.Module): | |
| """One convolution, normalization, pooling and dropout stage.""" | |
| def __init__(self, in_channels, out_channels, kernel, dilation, padding, dropout): | |
| super().__init__() | |
| self.kernel = kernel | |
| self.dilation = dilation | |
| self.padding = padding | |
| self.conv = nn.Conv2d( | |
| in_channels, | |
| out_channels, | |
| kernel, | |
| dilation=dilation, | |
| padding=padding, | |
| bias=False, | |
| ) | |
| self.batch_norm = nn.BatchNorm2d(out_channels) | |
| self.pool = nn.AvgPool2d(2) | |
| self.dropout = nn.Dropout2d(dropout) | |
| def forward(self, features): | |
| """Transform and downsample one feature map.""" | |
| features = functional.relu(self.batch_norm(self.conv(features)), inplace=True) | |
| return self.dropout(self.pool(features)) | |
| def output_lengths(self, lengths): | |
| """Propagate valid time lengths through convolution and pooling.""" | |
| convolved = lengths + 2 * self.padding[0] - self.dilation[0] * ( | |
| self.kernel[0] - 1 | |
| ) | |
| return torch.div(convolved.clamp_min(0), 2, rounding_mode="floor") | |
| def masked_mean_max(features, lengths): | |
| """Pool valid time frames by adding their mean and maximum.""" | |
| lengths = lengths.clamp(min=1, max=features.size(2)) | |
| positions = torch.arange(features.size(2), device=features.device).view(1, 1, -1, 1) | |
| mask = positions < lengths.view(-1, 1, 1, 1) | |
| mean = (features * mask).sum(2) / lengths.view(-1, 1, 1) | |
| maximum = features.masked_fill(~mask, float("-inf")).max(2).values | |
| return mean + torch.where(torch.isfinite(maximum), maximum, torch.zeros_like(maximum)) | |
| class MTRCNNBranch(nn.Module): | |
| """One temporal-resolution branch of MTRCNN.""" | |
| def __init__(self, stage_specs, dropout): | |
| super().__init__() | |
| channels = [1, 16, 32, 64] | |
| self.stages = nn.ModuleList( | |
| ConvStage(channels[index], channels[index + 1], *specification, dropout) | |
| for index, specification in enumerate(stage_specs) | |
| ) | |
| self.mix_style = MixStyle() | |
| self.frequency_projection = nn.Linear(self._frequency_bins(), 1) | |
| def _frequency_bins(self): | |
| with torch.no_grad(): | |
| features = torch.zeros(1, 1, 128, N_MELS) | |
| for stage in self.stages: | |
| features = stage(features) | |
| return features.shape[-1] | |
| def forward(self, features, lengths, domains, augment): | |
| """Encode one temporal-resolution branch.""" | |
| for index, stage in enumerate(self.stages): | |
| features = stage(features) | |
| lengths = stage.output_lengths(lengths) | |
| if index == 0 and augment: | |
| features = self.mix_style(features, domains) | |
| pooled = masked_mean_max(features, lengths) | |
| return functional.relu(self.frequency_projection(pooled).squeeze(-1), inplace=True) | |
| class GradientReversal(torch.autograd.Function): | |
| """Identity forward pass with a sign-reversed backward pass.""" | |
| def forward(ctx, features, strength): | |
| """Return features unchanged and retain the reversal strength.""" | |
| ctx.strength = strength | |
| return features.view_as(features) | |
| def backward(ctx, gradient): | |
| """Reverse and scale the upstream embedding gradient.""" | |
| return -ctx.strength * gradient, None | |
| class MTRCNNDG(nn.Module): | |
| """MTRCNN with spectral style, adversarial, and conditional domain heads.""" | |
| def __init__(self, dropout=0.2): | |
| super().__init__() | |
| self.fourier_mix = FourierMix() | |
| self.input_batch_norm = nn.BatchNorm2d(N_MELS) | |
| specifications = [ | |
| [((3, 3), (1, 1), (1, 1)), ((3, 3), (2, 1), (2, 0)), ((3, 3), (3, 1), (3, 0))], | |
| [((5, 5), (1, 1), (2, 2)), ((5, 5), (2, 1), (4, 1)), ((5, 5), (3, 1), (6, 1))], | |
| [((7, 7), (1, 1), (3, 3)), ((7, 7), (2, 1), (6, 2)), ((7, 7), (3, 1), (9, 2))], | |
| ] | |
| self.branches = nn.ModuleList(MTRCNNBranch(specs, dropout) for specs in specifications) | |
| self.embedding = nn.Linear(64 * 3, 32) | |
| self.species_classifier = nn.Linear(32, len(SPECIES)) | |
| self.domain_classifier = nn.Linear(32, len(DOMAINS)) | |
| self.aux_domain_classifier = nn.Linear(32, len(DOMAINS)) | |
| def forward(self, features, lengths, domains, domain_strength=0.0, augment=False): | |
| """Return species, domain, auxiliary, and embedding outputs.""" | |
| if augment: | |
| features = self.fourier_mix(features, domains) | |
| features = features.unsqueeze(1).transpose(1, 3) | |
| features = self.input_batch_norm(features).transpose(1, 3) | |
| branch_outputs = [ | |
| branch(features, lengths, domains, augment) for branch in self.branches | |
| ] | |
| embedding = functional.gelu(self.embedding(torch.cat(branch_outputs, dim=1))) | |
| reversed_embedding = GradientReversal.apply(embedding, domain_strength) | |
| auxiliary = self.aux_domain_classifier | |
| return { | |
| "embedding": embedding, | |
| "species_logits": self.species_classifier(embedding), | |
| "domain_logits": self.domain_classifier(reversed_embedding), | |
| "aux_domain_logits": auxiliary(embedding), | |
| "frozen_aux_domain_logits": functional.linear( | |
| embedding, | |
| auxiliary.weight.detach(), | |
| auxiliary.bias.detach(), | |
| ), | |
| } | |
| def supervised_contrastive(embedding, labels, temperature=0.01): | |
| """Pull embeddings of the same species together.""" | |
| normalized = functional.normalize(embedding, dim=1) | |
| similarity = normalized @ normalized.T / temperature | |
| identity = torch.eye(len(labels), dtype=torch.bool, device=labels.device) | |
| positives = labels[:, None].eq(labels[None, :]) & ~identity | |
| similarity = similarity.masked_fill(identity, float("-inf")) | |
| log_probability = similarity - torch.logsumexp(similarity, dim=1, keepdim=True) | |
| log_probability = torch.where(positives, log_probability, torch.zeros_like(log_probability)) | |
| counts = positives.sum(1) | |
| valid = counts > 0 | |
| if not valid.any(): | |
| return embedding.sum() * 0 | |
| return -(log_probability.sum(1)[valid] / counts[valid]).mean() | |
| def conditional_domain_losses(logits, species_labels): | |
| """Return class-conditional domain entropy and balance losses.""" | |
| probabilities = functional.softmax(logits, dim=1).clamp_min(1e-8) | |
| entropy_losses = [] | |
| balance_losses = [] | |
| for species in species_labels.unique(): | |
| class_probabilities = probabilities[species_labels == species] | |
| entropy_losses.append((class_probabilities * class_probabilities.log()).sum(1).mean()) | |
| mean_probability = class_probabilities.mean(0) | |
| balance_losses.append( | |
| (mean_probability * (mean_probability.log() + math.log(len(DOMAINS)))).sum() | |
| ) | |
| return torch.stack(entropy_losses).mean(), torch.stack(balance_losses).mean() | |
| def training_loss(outputs, species_labels, domain_labels, priors, epoch): | |
| """Compute the full MTRCNN-DG objective and its components.""" | |
| adjusted_logits = outputs["species_logits"] + priors.clamp_min(1e-8).log() | |
| species_loss = functional.cross_entropy(adjusted_logits, species_labels) | |
| domain_loss = functional.cross_entropy(outputs["domain_logits"], domain_labels) | |
| auxiliary_loss = functional.cross_entropy(outputs["aux_domain_logits"], domain_labels) | |
| contrastive_loss = supervised_contrastive(outputs["embedding"], species_labels) | |
| ccde_loss, cdb_loss = conditional_domain_losses( | |
| outputs["frozen_aux_domain_logits"], species_labels | |
| ) | |
| conditional_weight = 0.05 if epoch >= 5 else 0.0 | |
| total = ( | |
| species_loss | |
| + domain_loss | |
| + auxiliary_loss | |
| + 0.5 * contrastive_loss | |
| + conditional_weight * (ccde_loss + cdb_loss) | |
| ) | |
| return { | |
| "loss": total, | |
| "species": species_loss, | |
| "domain": domain_loss, | |
| "aux_domain": auxiliary_loss, | |
| "contrastive": contrastive_loss, | |
| "ccde": ccde_loss, | |
| "cdb": cdb_loss, | |
| } | |
| def smoke(args): | |
| """Exercise frontend, augmentations, all heads, losses, and backward pass.""" | |
| set_seed(args.seed) | |
| multi_domain_species = {0, 1, 2, 6, 7, 8} | |
| fold_ids = [ | |
| f"S_{species + 1}_D_{domain + 1}_{row + 1}" | |
| for species in range(len(SPECIES)) | |
| for domain in range(2 if species in multi_domain_species else 1) | |
| for row in range(2) | |
| ] | |
| training_ids, validation_ids, held_domains = held_domain_split( | |
| fold_ids, 0, seed=args.seed | |
| ) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| dataset = MosquitoDataset( | |
| None, | |
| synthetic_ids(4), | |
| args.seconds, | |
| training=True, | |
| balance_power=0.5, | |
| synthetic=True, | |
| ) | |
| sampler = WeightedRandomSampler( | |
| dataset.sample_weights, | |
| num_samples=args.batch_size, | |
| replacement=True, | |
| generator=torch.Generator().manual_seed(args.seed), | |
| ) | |
| batch = next( | |
| iter( | |
| DataLoader( | |
| dataset, | |
| batch_size=args.batch_size, | |
| sampler=sampler, | |
| collate_fn=AudioCollator(), | |
| ) | |
| ) | |
| ) | |
| waveforms = batch["waveforms"].to(device) | |
| lengths = batch["lengths"].to(device) | |
| labels = batch["species"].to(device) | |
| domains = batch["domains"].to(device) | |
| mean, std = compute_normalization( | |
| dataset, | |
| size=min(20, len(dataset)), | |
| batch_size=args.batch_size, | |
| workers=0, | |
| device=device, | |
| seed=args.seed, | |
| min_hz=MIN_HZ, | |
| ) | |
| frontend = LogMelFrontend(mean, std).to(device) | |
| model = MTRCNNDG().to(device).train() | |
| features, frame_lengths = frontend(waveforms, lengths) | |
| outputs = model(features, frame_lengths, domains, domain_strength=0.3, augment=True) | |
| priors = torch.full((len(SPECIES),), 1 / len(SPECIES), device=device) | |
| losses = training_loss(outputs, labels, domains, priors, epoch=5) | |
| losses["loss"].backward() | |
| assert outputs["species_logits"].shape == (args.batch_size, len(SPECIES)) | |
| assert outputs["domain_logits"].shape == (args.batch_size, len(DOMAINS)) | |
| assert all(torch.isfinite(value) for value in losses.values()) | |
| assert all( | |
| parameter.grad is None or torch.isfinite(parameter.grad).all() | |
| for parameter in model.parameters() | |
| ) | |
| result = { | |
| "device": str(device), | |
| "features": list(features.shape), | |
| "embedding": list(outputs["embedding"].shape), | |
| "parameters": sum(parameter.numel() for parameter in model.parameters()), | |
| "normalization": { | |
| "mean": [round(mean.min().item(), 4), round(mean.max().item(), 4)], | |
| "std": [round(std.min().item(), 4), round(std.max().item(), 4)], | |
| }, | |
| "fold": { | |
| "train": len(training_ids), | |
| "validation": len(validation_ids), | |
| "held_domains": { | |
| SPECIES[species]: DOMAINS[domain] if domain is not None else "same-domain" | |
| for species, domain in held_domains.items() | |
| }, | |
| }, | |
| "losses": {name: round(value.item(), 6) for name, value in losses.items()}, | |
| } | |
| print(json.dumps(result, indent=1)) | |
| def add_train_parser(commands): | |
| """Register the train command and its options.""" | |
| train_parser = commands.add_parser("train") | |
| train_parser.add_argument("--data", default="/tmp/mosquito-audio") | |
| train_parser.add_argument("--output", default="/tmp/mosquito-mtrcnn") | |
| train_parser.add_argument("--fold", type=int, default=0) | |
| train_parser.add_argument("--epochs", type=int, default=20) | |
| train_parser.add_argument("--max-steps", type=int, default=-1) | |
| train_parser.add_argument("--train-size", type=int, default=None) | |
| train_parser.add_argument("--validation-size", type=int, default=None) | |
| train_parser.add_argument("--stats-size", type=int, default=10_000) | |
| train_parser.add_argument("--reference-size", type=int, default=20_000) | |
| train_parser.add_argument("--batch-size", type=int, default=128) | |
| train_parser.add_argument("--eval-batch-size", type=int, default=256) | |
| train_parser.add_argument("--learning-rate", type=float, default=1e-3) | |
| train_parser.add_argument("--weight-decay", type=float, default=1e-4) | |
| train_parser.add_argument("--warmup-ratio", type=float, default=0.05) | |
| train_parser.add_argument("--gradient-clip", type=float, default=5.0) | |
| train_parser.add_argument("--dropout", type=float, default=0.2) | |
| train_parser.add_argument("--balance-power", type=float, default=1.0) | |
| train_parser.add_argument("--seconds", type=float, default=SECONDS) | |
| train_parser.add_argument("--min-hz", type=float, default=MIN_HZ) | |
| train_parser.add_argument("--level-rms", type=float, default=LEVEL_RMS) | |
| train_parser.add_argument("--lifter", type=int, default=0) | |
| train_parser.add_argument("--patience", type=int, default=4) | |
| train_parser.add_argument("--workers", type=int, default=max(0, cpus() - 1)) | |
| train_parser.add_argument("--seed", type=int, default=42) | |
| train_parser.add_argument("--synthetic", action="store_true") | |
| train_parser.add_argument("--synthetic-rows", type=int, default=4) | |
| train_parser.add_argument("--test", action="store_true") | |
| train_parser.add_argument("--test-size", type=int, default=None) | |
| train_parser.set_defaults(run=train) | |
| def main(): | |
| """Parse command-line arguments.""" | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| commands = parser.add_subparsers(dest="command", required=True) | |
| smoke_parser = commands.add_parser("smoke") | |
| smoke_parser.add_argument("--batch-size", type=int, default=10) | |
| smoke_parser.add_argument("--seconds", type=float, default=SECONDS) | |
| smoke_parser.add_argument("--seed", type=int, default=42) | |
| smoke_parser.set_defaults(run=smoke) | |
| add_train_parser(commands) | |
| evaluate_parser = commands.add_parser("evaluate") | |
| evaluate_parser.add_argument("--checkpoint", required=True) | |
| evaluate_parser.add_argument("--data", default="/tmp/mosquito-audio") | |
| evaluate_parser.add_argument("--output", default="/tmp/mosquito-mtrcnn-eval.json") | |
| evaluate_parser.add_argument("--test-size", type=int, default=None) | |
| evaluate_parser.add_argument("--eval-batch-size", type=int, default=128) | |
| evaluate_parser.add_argument("--workers", type=int, default=max(0, cpus() - 1)) | |
| evaluate_parser.add_argument("--export", default=None) | |
| evaluate_parser.add_argument("--repo", default=None) | |
| evaluate_parser.set_defaults(run=evaluate) | |
| predict_parser = commands.add_parser("predict") | |
| predict_parser.add_argument("--model", default=REPO) | |
| predict_parser.add_argument("files", nargs="+") | |
| predict_parser.set_defaults(run=predict) | |
| card_parser = commands.add_parser("card") | |
| card_parser.add_argument("--repo", default=REPO) | |
| card_parser.set_defaults(run=card) | |
| args = parser.parse_args() | |
| args.run(args) | |
| if __name__ == "__main__": | |
| main() | |