Compare commits

..

17 Commits

Author SHA1 Message Date
mbsantiago
9e2697458d Bump version: 2.0.0b2 -> 2.0.0b3
Some checks failed
CI / Checks (push) Has been cancelled
CI / Tests (Python ${{ matrix.python-version }}) (3.10) (push) Has been cancelled
CI / Tests (Python ${{ matrix.python-version }}) (3.11) (push) Has been cancelled
CI / Tests (Python ${{ matrix.python-version }}) (3.12) (push) Has been cancelled
Docs Pages / Build Docs (push) Has been cancelled
Docs Pages / Deploy Docs (push) Has been cancelled
2026-08-08 14:00:50 +01:00
Santiago Martinez Balvanera
afafccc26d
Merge pull request #76 from macaodha/fix/dependencies-issues
build: trim default runtime dependencies
2026-08-08 13:55:21 +01:00
mbsantiago
981b625c34 Add tensorboard import message 2026-08-08 13:50:36 +01:00
mbsantiago
f4a0421ef3 build: trim runtime dependencies 2026-08-08 13:40:15 +01:00
mbsantiago
2b582fc2a0 Fix typing issues 2026-08-08 13:39:32 +01:00
Santiago Martinez Balvanera
bbe89e2315
Merge pull request #75 from macaodha/feat/compile-model
Add runtime model compilation options
2026-08-08 12:50:59 +01:00
mbsantiago
586e78814f refactor: remove ambiguous training precision option 2026-08-08 12:34:07 +01:00
mbsantiago
c8f0f2bee1 docs: document runtime compilation options 2026-08-08 12:18:55 +01:00
mbsantiago
bad5d4f4fc test: cover compile idempotency paths 2026-08-08 12:04:19 +01:00
mbsantiago
8166db8f9c feat: add runtime model compilation options 2026-08-08 12:02:00 +01:00
mbsantiago
9f22fd097f Merge branch 'feat/compile-model' of github.com:macaodha/batdetect2 into feat/compile-model 2026-08-08 11:04:52 +01:00
Santiago Martinez Balvanera
bcc9cb6d00 Expand the cosine annealing config 2026-08-08 11:01:38 +01:00
Santiago Martinez Balvanera
ead0adf284 Allow passing the logger object directly to the train workflow 2026-08-08 11:01:38 +01:00
Santiago Martinez Balvanera
7492280218 Add setting for compiling the model before train 2026-08-08 11:01:38 +01:00
Santiago Martinez Balvanera
1bab80f8c6 Expand the cosine annealing config 2026-08-04 08:46:51 +01:00
Santiago Martinez Balvanera
573a0ca392 Allow passing the logger object directly to the train workflow 2026-08-04 08:46:32 +01:00
Santiago Martinez Balvanera
13e6f97fa7 Add setting for compiling the model before train 2026-08-04 08:45:29 +01:00
22 changed files with 2715 additions and 2426 deletions

View File

@ -6,7 +6,7 @@ Code for detecting and classifying bat echolocation calls in high-frequency
audio recordings. audio recordings.
> [!WARNING] > [!WARNING]
> `batdetect2` 2.0.0b2 is out. > `batdetect2` 2.0.0b3 is out.
> This is a beta release and we are gathering user feedback. > This is a beta release and we are gathering user feedback.
> If you run into issues or have feedback on the new workflows, please use the > If you run into issues or have feedback on the new workflows, please use the
> GitHub issues page to let us know. > GitHub issues page to let us know.
@ -68,13 +68,13 @@ can try the following:
### Installing BatDetect2 ### Installing BatDetect2
> [!NOTE] > [!NOTE]
> `2.0.0b2` is a pre-release on PyPI. > `2.0.0b3` is a pre-release on PyPI.
> You may need to request it explicitly by version, for example: > You may need to request it explicitly by version, for example:
> >
> ```bash > ```bash
> uvx --from batdetect2==2.0.0b2 batdetect2 > uvx --from batdetect2==2.0.0b3 batdetect2
> uv tool install batdetect2==2.0.0b2 > uv tool install batdetect2==2.0.0b3
> pip install batdetect2==2.0.0b2 > pip install batdetect2==2.0.0b3
> ``` > ```
If you have `uv` installed (if not, we recommend it; follow the instructions If you have `uv` installed (if not, we recommend it; follow the instructions

View File

@ -15,6 +15,9 @@ Defined in `batdetect2.api_v2`.
- `BatDetect2API.from_config(model_config=..., targets_config=..., ...)` - `BatDetect2API.from_config(model_config=..., targets_config=..., ...)`
- build a full model stack from config objects. - build a full model stack from config objects.
Both constructors accept `compile_model=True` to compile the detector after the
API is built.
## Common tasks ## Common tasks
- Load a checkpoint and run prediction on one file. - Load a checkpoint and run prediction on one file.
@ -22,6 +25,8 @@ Defined in `batdetect2.api_v2`.
- Save predictions in one of the supported output formats. - Save predictions in one of the supported output formats.
- Evaluate a model on labelled data. - Evaluate a model on labelled data.
- Fine-tune an existing checkpoint on new targets. - Fine-tune an existing checkpoint on new targets.
- Compile the detector explicitly with `BatDetect2API.compile()` when you want
to opt into PyTorch runtime compilation from Python.
## Generated reference ## Generated reference

View File

@ -7,6 +7,8 @@ Defined in `batdetect2.inference.config`.
## Top-level fields ## Top-level fields
- `compile_model`
- compile the detector before batch prediction. This is off by default.
- `loader` - `loader`
- data-loader settings for inference. - data-loader settings for inference.
- `clipping` - `clipping`
@ -34,8 +36,19 @@ Override `InferenceConfig` when:
- long recordings need different clipping behavior, - long recordings need different clipping behavior,
- you want to tune batch size for your hardware, - you want to tune batch size for your hardware,
- you want to opt into runtime model compilation for repeated predictions,
- you need reproducible prediction settings across runs. - you need reproducible prediction settings across runs.
## Runtime compilation
Set `compile_model: true` to compile the detector before batch inference. This
can help when you run repeated predictions with stable input shapes. For a
single short run, the compile step can cost more time than it saves.
In Python, you can also compile explicitly with `BatDetect2API.compile()` or by
passing `compile_model=True` to `BatDetect2API.from_checkpoint(...)` or
`BatDetect2API.from_config(...)`.
## Related pages ## Related pages
- Tune inference clipping: - Tune inference clipping:

View File

@ -7,6 +7,8 @@ Defined in `batdetect2.train.config`.
## Top-level fields ## Top-level fields
- `compile_model`
- compile the detector before training starts. This is off by default.
- `train_loader` - `train_loader`
- training data loading and clipping settings. - training data loading and clipping settings.
- `val_loader` - `val_loader`
@ -33,10 +35,18 @@ Use `TrainingConfig` when you want to change things like:
- batch size, - batch size,
- augmentation, - augmentation,
- optimiser and scheduler settings, - optimiser and scheduler settings,
- runtime options such as model compilation,
- number of epochs, - number of epochs,
- validation frequency, - validation frequency,
- checkpoint behaviour. - checkpoint behaviour.
## Runtime options
Use `compile_model: true` to call `torch.compile` on the detector used during
training. This can help on longer runs with stable tensor shapes, but it may be
slower for short CPU-only experiments because PyTorch has to compile the graph
before it can reuse it.
Example files live under `example_data/configs/`, including Example files live under `example_data/configs/`, including
`example_data/configs/training.yaml`. `example_data/configs/training.yaml`.

View File

@ -136,7 +136,7 @@ clean: clean-build clean-pyc clean-test clean-docs
# Train on example data. # Train on example data.
example-train OPTIONS="": example-train OPTIONS="":
uv run batdetect2 train \ uv run batdetect2 -v train \
--val-dataset example_data/dataset.yaml \ --val-dataset example_data/dataset.yaml \
--base-dir . \ --base-dir . \
--targets example_data/targets.yaml \ --targets example_data/targets.yaml \

View File

@ -1,6 +1,6 @@
[project] [project]
name = "batdetect2" name = "batdetect2"
version = "2.0.0b2" version = "2.0.0b3"
description = "Deep learning model for detecting and classifying bat echolocation calls in high frequency audio recordings." description = "Deep learning model for detecting and classifying bat echolocation calls in high frequency audio recordings."
authors = [ authors = [
{ "name" = "Oisin Mac Aodha", "email" = "oisin.macaodha@ed.ac.uk" }, { "name" = "Oisin Mac Aodha", "email" = "oisin.macaodha@ed.ac.uk" },
@ -11,10 +11,9 @@ dependencies = [
"deepmerge>=2.0", "deepmerge>=2.0",
"hydra-core>=1.3.2", "hydra-core>=1.3.2",
"librosa>=0.10.1", "librosa>=0.10.1",
"lightning[extra]==2.5.0", "lightning==2.5.0",
"loguru>=0.7.3", "loguru>=0.7.3",
"matplotlib>=3.7.1", "matplotlib>=3.7.1",
"netcdf4>=1.6.5",
"numpy>=1.23.5", "numpy>=1.23.5",
"pandas>=1.5.3", "pandas>=1.5.3",
"pydantic>=2.0.0", "pydantic>=2.0.0",
@ -24,9 +23,10 @@ dependencies = [
"seaborn>=0.13.2", "seaborn>=0.13.2",
"soundevent[audio,geometry,plot]>=2.10.0", "soundevent[audio,geometry,plot]>=2.10.0",
"soundfile>=0.12.1", "soundfile>=0.12.1",
"tensorboard>=2.16.2", "tabulate>=0.10.0",
"torch>=2.0.0", "torch>=2.0.0",
"torchaudio>=2.0.0", "torchaudio>=2.0.0",
"tqdm>=4.70.0",
"xarray>=2024.0.0", "xarray>=2024.0.0",
] ]
requires-python = ">=3.10,<3.14" requires-python = ">=3.10,<3.14"
@ -88,6 +88,7 @@ dev = [
"deepdiff>=8.6.1", "deepdiff>=8.6.1",
"pytest-xdist[psutil]>=3.8.0", "pytest-xdist[psutil]>=3.8.0",
] ]
tensorboard = ["tensorboard>=2.16.2"]
dvclive = ["dvclive>=3.48.2"] dvclive = ["dvclive>=3.48.2"]
mlflow = ["mlflow>=3.1.1"] mlflow = ["mlflow>=3.1.1"]
gradio = [ gradio = [

View File

@ -8,6 +8,7 @@ if TYPE_CHECKING:
import numpy as np import numpy as np
import torch import torch
from lightning.pytorch.loggers import Logger
from soundevent import data from soundevent import data
from batdetect2.audio import AudioConfig, AudioLoader from batdetect2.audio import AudioConfig, AudioLoader
@ -152,6 +153,19 @@ class BatDetect2API:
self.model.eval() self.model.eval()
def compile(self) -> "BatDetect2API":
"""Compile the detector path used by inference.
Returns
-------
BatDetect2API
This API instance with the detector compiled.
"""
from batdetect2.models import compile_model
compile_model(self.model)
return self
def load_annotations( def load_annotations(
self, self,
path: data.PathLike, path: data.PathLike,
@ -192,6 +206,7 @@ class BatDetect2API:
train_config: TrainingConfig | None = None, train_config: TrainingConfig | None = None,
logger_config: LoggerConfig | None = None, logger_config: LoggerConfig | None = None,
logging_callbacks: Sequence[LoggingCallback[TrainLoggingContext]] = (), logging_callbacks: Sequence[LoggingCallback[TrainLoggingContext]] = (),
train_logger: Logger | None = None,
): ):
"""Train the current model on a set of annotations. """Train the current model on a set of annotations.
@ -226,6 +241,9 @@ class BatDetect2API:
Training logger config override. Training logger config override.
logging_callbacks : Sequence[LoggingCallback[TrainLoggingContext]], optional logging_callbacks : Sequence[LoggingCallback[TrainLoggingContext]], optional
Extra logging callbacks to run during training setup. Extra logging callbacks to run during training setup.
train_logger : Logger | None, optional
Pre-built Lightning logger to use for training. If omitted, one is
built from ``logger_config``.
Returns Returns
------- -------
@ -255,6 +273,7 @@ class BatDetect2API:
audio_config=audio_config or self.audio_config, audio_config=audio_config or self.audio_config,
logger_config=logger_config or self.logging_config.train, logger_config=logger_config or self.logging_config.train,
logging_callbacks=logging_callbacks, logging_callbacks=logging_callbacks,
train_logger=train_logger,
) )
self.model.eval() self.model.eval()
return self return self
@ -979,6 +998,7 @@ class BatDetect2API:
inference_config: InferenceConfig | None = None, inference_config: InferenceConfig | None = None,
outputs_config: OutputsConfig | None = None, outputs_config: OutputsConfig | None = None,
logging_config: AppLoggingConfig | None = None, logging_config: AppLoggingConfig | None = None,
compile_model: bool = False,
) -> "BatDetect2API": ) -> "BatDetect2API":
"""Build an API instance from config objects. """Build an API instance from config objects.
@ -1004,6 +1024,8 @@ class BatDetect2API:
Output config. If omitted, the default outputs config is used. Output config. If omitted, the default outputs config is used.
logging_config : AppLoggingConfig | None, optional logging_config : AppLoggingConfig | None, optional
Logging config. If omitted, the default logging config is used. Logging config. If omitted, the default logging config is used.
compile_model : bool, optional
If ``True``, compile the detector path after building the API.
Returns Returns
------- -------
@ -1086,7 +1108,7 @@ class BatDetect2API:
), ),
) )
return cls( api = cls(
model_config=model_config, model_config=model_config,
audio_config=audio_config, audio_config=audio_config,
train_config=train_config, train_config=train_config,
@ -1105,6 +1127,11 @@ class BatDetect2API:
output_transform=output_transform, output_transform=output_transform,
) )
if compile_model:
api.compile()
return api
@classmethod @classmethod
def from_checkpoint( def from_checkpoint(
cls, cls,
@ -1115,6 +1142,7 @@ class BatDetect2API:
inference_config: InferenceConfig | None = None, inference_config: InferenceConfig | None = None,
outputs_config: OutputsConfig | None = None, outputs_config: OutputsConfig | None = None,
logging_config: AppLoggingConfig | None = None, logging_config: AppLoggingConfig | None = None,
compile_model: bool = False,
) -> "BatDetect2API": ) -> "BatDetect2API":
"""Build an API instance from a saved checkpoint. """Build an API instance from a saved checkpoint.
@ -1135,6 +1163,8 @@ class BatDetect2API:
Output config override. Output config override.
logging_config : AppLoggingConfig | None, optional logging_config : AppLoggingConfig | None, optional
Logging config override. Logging config override.
compile_model : bool, optional
If ``True``, compile the detector path after building the API.
Returns Returns
------- -------
@ -1218,7 +1248,7 @@ class BatDetect2API:
transform=output_transform, transform=output_transform,
) )
return cls( api = cls(
model_config=model_config, model_config=model_config,
audio_config=audio_config, audio_config=audio_config,
train_config=train_config, train_config=train_config,
@ -1237,6 +1267,11 @@ class BatDetect2API:
output_transform=output_transform, output_transform=output_transform,
) )
if compile_model:
api.compile()
return api
def _set_trainable_parameters( def _set_trainable_parameters(
self, self,
trainable: Literal["all", "heads", "classifier_head", "size_head"], trainable: Literal["all", "heads", "classifier_head", "size_head"],

View File

@ -10,6 +10,7 @@ from batdetect2.inference.clips import get_clips_from_files
from batdetect2.inference.config import InferenceConfig from batdetect2.inference.config import InferenceConfig
from batdetect2.inference.dataset import build_inference_loader from batdetect2.inference.dataset import build_inference_loader
from batdetect2.inference.lightning import InferenceModule from batdetect2.inference.lightning import InferenceModule
from batdetect2.models import compile_model
from batdetect2.models.types import ModelProtocol from batdetect2.models.types import ModelProtocol
from batdetect2.outputs import ( from batdetect2.outputs import (
OutputsConfig, OutputsConfig,
@ -71,6 +72,9 @@ def run_batch_inference(
batch_size=batch_size, batch_size=batch_size,
) )
if inference_config.compile_model:
compile_model(model)
module = InferenceModule( module = InferenceModule(
model, model,
output_transform=output_transform, output_transform=output_transform,

View File

@ -15,6 +15,7 @@ class ClipingConfig(BaseConfig):
class InferenceConfig(BaseConfig): class InferenceConfig(BaseConfig):
compile_model: bool = False
loader: InferenceLoaderConfig = Field( loader: InferenceLoaderConfig = Field(
default_factory=InferenceLoaderConfig default_factory=InferenceLoaderConfig
) )

View File

@ -201,7 +201,14 @@ def create_tensorboard_logger(
experiment_name: str | None = None, experiment_name: str | None = None,
run_name: str | None = None, run_name: str | None = None,
) -> Logger: ) -> Logger:
from lightning.pytorch.loggers import TensorBoardLogger try:
from lightning.pytorch.loggers import TensorBoardLogger
except ImportError as error:
raise ValueError(
"TensorBoard is not installed and cannot be used for logging. "
"Make sure you have it installed by running `pip install tensorboard` "
"or `uv add tensorboard`"
) from error
if log_dir is None: if log_dir is None:
log_dir = Path(config.log_dir) log_dir = Path(config.log_dir)

View File

@ -100,6 +100,7 @@ __all__ = [
"ModelConfig", "ModelConfig",
"build_model", "build_model",
"build_model_with_new_targets", "build_model_with_new_targets",
"compile_model",
] ]
@ -319,3 +320,15 @@ def build_model_with_new_targets(
dimension_names=roi_mapper.dimension_names, dimension_names=roi_mapper.dimension_names,
config=model.get_config(), config=model.get_config(),
) )
def compile_model(model: ModelProtocol) -> ModelProtocol:
"""Compile the detector path used by training and inference."""
if not isinstance(model.detector, torch.nn.Module):
raise TypeError("Detector must be a torch.nn.Module to compile.")
if getattr(model.detector, "_compiled_call_impl", None) is not None:
return model
model.detector.compile()
return model

View File

@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import List, Literal, Sequence from typing import List, Literal, Sequence, TypedDict
from uuid import UUID from uuid import UUID
import numpy as np import numpy as np
@ -26,6 +26,11 @@ class ParquetOutputConfig(BaseConfig):
include_geometry: bool = True include_geometry: bool = True
class ClipInfo(TypedDict):
clip: data.Clip
preds: list[Detection]
class ParquetFormatter(OutputFormatterProtocol[ClipDetections]): class ParquetFormatter(OutputFormatterProtocol[ClipDetections]):
def __init__( def __init__(
self, self,
@ -120,7 +125,7 @@ class ParquetFormatter(OutputFormatterProtocol[ClipDetections]):
else: else:
df = pd.read_parquet(path) df = pd.read_parquet(path)
predictions_by_clip = {} predictions_by_clip: dict[UUID, ClipInfo] = {}
for _, row in df.iterrows(): for _, row in df.iterrows():
clip_uuid = row["clip_uuid"] clip_uuid = row["clip_uuid"]

View File

@ -185,25 +185,25 @@ def plot_clip_evaluation(
label="found GT", label="found GT",
edgecolor=gt_color, edgecolor=gt_color,
facecolor="none" if not fill else gt_color, facecolor="none" if not fill else gt_color,
linestyle=gt_linestyle, linestyle=gt_linestyle, # type: ignore
), ),
patches.Patch( patches.Patch(
label="missed GT", label="missed GT",
edgecolor=missed_gt_color, edgecolor=missed_gt_color,
facecolor="none" if not fill else missed_gt_color, facecolor="none" if not fill else missed_gt_color,
linestyle=missed_gt_linestyle, linestyle=missed_gt_linestyle, # type: ignore
), ),
patches.Patch( patches.Patch(
label="true Det", label="true Det",
edgecolor=true_pred_color, edgecolor=true_pred_color,
facecolor="none" if not fill else true_pred_color, facecolor="none" if not fill else true_pred_color,
linestyle=true_pred_linestyle, linestyle=true_pred_linestyle, # type: ignore
), ),
patches.Patch( patches.Patch(
label="false Det", label="false Det",
edgecolor=false_pred_color, edgecolor=false_pred_color,
facecolor="none" if not fill else false_pred_color, facecolor="none" if not fill else false_pred_color,
linestyle=false_pred_linestyle, linestyle=false_pred_linestyle, # type: ignore
), ),
] ]
) )

View File

@ -1,9 +1,9 @@
"""Plot heatmaps.""" """Plot heatmaps."""
import matplotlib.pyplot as plt
import numpy as np import numpy as np
import torch import torch
from matplotlib import axes, patches from matplotlib import axes, patches
from matplotlib.cm import get_cmap
from matplotlib.colors import Colormap, LinearSegmentedColormap, to_rgba from matplotlib.colors import Colormap, LinearSegmentedColormap, to_rgba
from batdetect2.plotting.common import create_ax from batdetect2.plotting.common import create_ax
@ -80,7 +80,7 @@ def plot_classification_heatmap(
raise ValueError("Inconsistent number of class names") raise ValueError("Inconsistent number of class names")
if not isinstance(cmap, Colormap): if not isinstance(cmap, Colormap):
cmap = get_cmap(cmap) cmap = plt.get_cmap(cmap)
handles = [] handles = []

View File

@ -39,6 +39,7 @@ class PLTrainerConfig(BaseConfig):
class TrainingConfig(BaseConfig): class TrainingConfig(BaseConfig):
compile_model: bool = False
train_loader: TrainLoaderConfig = Field(default_factory=TrainLoaderConfig) train_loader: TrainLoaderConfig = Field(default_factory=TrainLoaderConfig)
val_loader: ValLoaderConfig = Field(default_factory=ValLoaderConfig) val_loader: ValLoaderConfig = Field(default_factory=ValLoaderConfig)
optimizer: OptimizerConfig = Field(default_factory=AdamOptimizerConfig) optimizer: OptimizerConfig = Field(default_factory=AdamOptimizerConfig)

View File

@ -23,21 +23,6 @@ __all__ = [
] ]
class CosineAnnealingSchedulerConfig(BaseConfig):
"""Configuration for ``CosineAnnealingLR``.
Attributes
----------
name : Literal["cosine_annealing"]
Discriminator field used by the scheduler registry.
t_max : int
Number of epochs to complete one cosine cycle.
"""
name: Literal["cosine_annealing"] = "cosine_annealing"
t_max: int = 200
scheduler_registry: Registry[LRScheduler, [Optimizer]] = Registry("scheduler") scheduler_registry: Registry[LRScheduler, [Optimizer]] = Registry("scheduler")
@ -53,6 +38,24 @@ class SchedulerImportConfig(ImportConfig):
name: Literal["import"] = "import" name: Literal["import"] = "import"
class CosineAnnealingSchedulerConfig(BaseConfig):
"""Configuration for ``CosineAnnealingLR``.
Attributes
----------
name : Literal["cosine_annealing"]
Discriminator field used by the scheduler registry.
t_max : int
Number of epochs to complete one cosine cycle.
eta_min : float, optional
Minimum learning rate. Defaults to 0.
"""
name: Literal["cosine_annealing"] = "cosine_annealing"
t_max: int = 200
eta_min: float = 0
@scheduler_registry.register(CosineAnnealingSchedulerConfig) @scheduler_registry.register(CosineAnnealingSchedulerConfig)
def build_cosine_scheduler( def build_cosine_scheduler(
config: CosineAnnealingSchedulerConfig, config: CosineAnnealingSchedulerConfig,
@ -63,7 +66,11 @@ def build_cosine_scheduler(
``t_max`` is interpreted in epochs because Lightning steps the scheduler ``t_max`` is interpreted in epochs because Lightning steps the scheduler
once per epoch when ``interval="epoch"`` is used. once per epoch when ``interval="epoch"`` is used.
""" """
return CosineAnnealingLR(optimizer, T_max=config.t_max) return CosineAnnealingLR(
optimizer,
T_max=config.t_max,
eta_min=config.eta_min,
)
SchedulerConfig = Annotated[ SchedulerConfig = Annotated[

View File

@ -15,7 +15,7 @@ from batdetect2.logging import (
LoggingCallback, LoggingCallback,
build_logger, build_logger,
) )
from batdetect2.models import ModelConfig, build_model from batdetect2.models import ModelConfig, build_model, compile_model
from batdetect2.models.types import ModelProtocol from batdetect2.models.types import ModelProtocol
from batdetect2.preprocess import PreprocessorProtocol, build_preprocessor from batdetect2.preprocess import PreprocessorProtocol, build_preprocessor
from batdetect2.targets import ( from batdetect2.targets import (
@ -71,6 +71,7 @@ def run_train(
run_name: str | None = None, run_name: str | None = None,
seed: int | None = None, seed: int | None = None,
logging_callbacks: Sequence[LoggingCallback[TrainLoggingContext]] = (), logging_callbacks: Sequence[LoggingCallback[TrainLoggingContext]] = (),
train_logger: Logger | None = None,
): ):
if seed is not None: if seed is not None:
seed_everything(seed) seed_everything(seed)
@ -164,7 +165,7 @@ def run_train(
roi_mapper=roi_mapper, roi_mapper=roi_mapper,
) )
train_logger = build_logger( train_logger = train_logger or build_logger(
logger_config or CSVLoggerConfig(), logger_config or CSVLoggerConfig(),
log_dir=log_dir, log_dir=log_dir,
experiment_name=experiment_name, experiment_name=experiment_name,
@ -206,6 +207,10 @@ def run_train(
run_name=run_name, run_name=run_name,
) )
if train_config.compile_model:
logger.info("Compiling detector...")
compile_model(module.model)
logger.info("Starting main training loop...") logger.info("Starting main training loop...")
trainer.fit( trainer.fit(
module, module,

View File

@ -1,6 +1,7 @@
import uuid import uuid
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional from typing import Any, Callable, List, Optional, cast
from uuid import uuid4 from uuid import uuid4
import lightning as L import lightning as L
@ -15,6 +16,7 @@ from batdetect2.audio.clips import build_clipper
from batdetect2.audio.types import AudioLoader, ClipperProtocol from batdetect2.audio.types import AudioLoader, ClipperProtocol
from batdetect2.data import DatasetConfig, load_dataset from batdetect2.data import DatasetConfig, load_dataset
from batdetect2.data.annotations.batdetect2 import BatDetect2FilesAnnotations from batdetect2.data.annotations.batdetect2 import BatDetect2FilesAnnotations
from batdetect2.models.types import ModelProtocol
from batdetect2.preprocess import build_preprocessor from batdetect2.preprocess import build_preprocessor
from batdetect2.preprocess.types import PreprocessorProtocol from batdetect2.preprocess.types import PreprocessorProtocol
from batdetect2.targets import ( from batdetect2.targets import (
@ -31,6 +33,12 @@ from batdetect2.train.lightning import build_training_module
from batdetect2.train.types import ClipLabeller from batdetect2.train.types import ClipLabeller
@dataclass
class DetectorCompileRecorder:
compile_count: int = 0
call_count: int = 0
@pytest.fixture @pytest.fixture
def example_data_dir() -> Path: def example_data_dir() -> Path:
pkg_dir = Path(__file__).parent.parent pkg_dir = Path(__file__).parent.parent
@ -156,12 +164,15 @@ def generate_whistle(tmp_path: Path):
offset = int((time - duration / 2) * samplerate) offset = int((time - duration / 2) * samplerate)
t = np.linspace(-duration / 2, duration / 2, frames, endpoint=False) t = np.linspace(-duration / 2, duration / 2, frames, endpoint=False)
data = signal.gausspulse( pulse = np.asarray(
t, signal.gausspulse(
fc=frequency, t,
bw=2 / (frequency * whistle_duration), fc=frequency,
bw=2 / (frequency * whistle_duration),
),
dtype=np.float64,
) )
wave = (np.roll(data, offset) * np.iinfo(np.int16).max).astype( wave = (np.roll(pulse, offset) * np.iinfo(np.int16).max).astype(
np.int16 np.int16
) )
sf.write(str(path), wave, samplerate, subtype="PCM_16") sf.write(str(path), wave, samplerate, subtype="PCM_16")
@ -363,6 +374,30 @@ def sample_audio_loader() -> AudioLoader:
return build_audio_loader() return build_audio_loader()
@pytest.fixture
def record_detector_compilation(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[[ModelProtocol], DetectorCompileRecorder]:
def factory(model: ModelProtocol) -> DetectorCompileRecorder:
recorder = DetectorCompileRecorder()
detector = cast(Any, model.detector)
original_call_impl = detector._call_impl
def compile_detector() -> None:
recorder.compile_count += 1
def compiled_call(*args, **kwargs):
recorder.call_count += 1
return original_call_impl(*args, **kwargs)
detector._compiled_call_impl = compiled_call
monkeypatch.setattr(detector, "compile", compile_detector)
return recorder
return factory
@pytest.fixture @pytest.fixture
def bat_tag() -> data.Tag: def bat_tag() -> data.Tag:
return data.Tag(key="class", value="bat") return data.Tag(key="class", value="bat")

View File

@ -153,6 +153,61 @@ def test_process_spectrogram_rejects_batched_input(
api_v2.process_spectrogram(spec) api_v2.process_spectrogram(spec)
def test_user_can_compile_api_detector(
api_v2: BatDetect2API,
example_audio_files: list[Path],
record_detector_compilation,
) -> None:
recorder = record_detector_compilation(api_v2.model)
audio = api_v2.load_audio(example_audio_files[0])
spec = api_v2.generate_spectrogram(audio)
api_v2.compile()
api_v2.compile()
api_v2.process_spectrogram(spec)
assert recorder.compile_count == 1
assert recorder.call_count == 1
def test_api_from_config_compiles_detector_when_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
compiled_models = []
def compile_model(model):
compiled_models.append(model)
return model
monkeypatch.setattr("batdetect2.models.compile_model", compile_model)
api = BatDetect2API.from_config(
compile_model=True,
)
assert compiled_models == [api.model]
def test_api_from_checkpoint_compiles_detector_when_requested(
tiny_checkpoint_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
compiled_models = []
def compile_model(model):
compiled_models.append(model)
return model
monkeypatch.setattr("batdetect2.models.compile_model", compile_model)
api = BatDetect2API.from_checkpoint(
tiny_checkpoint_path,
compile_model=True,
)
assert compiled_models == [api.model]
def test_user_can_read_top_class_and_other_class_scores( def test_user_can_read_top_class_and_other_class_scores(
api_v2: BatDetect2API, api_v2: BatDetect2API,
example_audio_files: list[Path], example_audio_files: list[Path],

View File

@ -3,6 +3,8 @@ from pathlib import Path
import pytest import pytest
from soundevent import data from soundevent import data
from batdetect2.api_v2 import BatDetect2API
from batdetect2.inference import InferenceConfig
from batdetect2.inference.batch import run_batch_inference from batdetect2.inference.batch import run_batch_inference
from batdetect2.targets import build_roi_mapping, build_targets from batdetect2.targets import build_roi_mapping, build_targets
from batdetect2.train import load_model_from_checkpoint from batdetect2.train import load_model_from_checkpoint
@ -53,3 +55,54 @@ def test_run_batch_inference_matches_single_clip_inference(
strict=True, strict=True,
): ):
assert_clip_detections_equal(batched, single) assert_clip_detections_equal(batched, single)
def test_run_batch_inference_compiles_detector_when_config_requests_compile(
example_annotations: list[data.ClipAnnotation],
record_detector_compilation,
) -> None:
api = BatDetect2API.from_config()
recorder = record_detector_compilation(api.model)
predictions = run_batch_inference(
api.model,
[example_annotations[0].clip],
targets=api.targets,
roi_mapper=api.roi_mapper,
audio_loader=api.audio_loader,
preprocessor=api.preprocessor,
output_transform=api.output_transform,
inference_config=InferenceConfig(compile_model=True),
batch_size=1,
num_workers=0,
)
assert predictions
assert recorder.compile_count == 1
assert recorder.call_count > 0
def test_run_batch_inference_does_not_recompile_compiled_detector(
example_annotations: list[data.ClipAnnotation],
record_detector_compilation,
) -> None:
api = BatDetect2API.from_config()
recorder = record_detector_compilation(api.model)
api.compile()
predictions = run_batch_inference(
api.model,
[example_annotations[0].clip],
targets=api.targets,
roi_mapper=api.roi_mapper,
audio_loader=api.audio_loader,
preprocessor=api.preprocessor,
output_transform=api.output_transform,
inference_config=InferenceConfig(compile_model=True),
batch_size=1,
num_workers=0,
)
assert predictions
assert recorder.compile_count == 1
assert recorder.call_count > 0

View File

@ -49,6 +49,16 @@ def build_default_module(
) )
def build_fast_train_config() -> TrainingConfig:
train_config = TrainingConfig()
train_config.trainer.limit_train_batches = 1
train_config.trainer.limit_val_batches = 1
train_config.trainer.log_every_n_steps = 1
train_config.train_loader.batch_size = 1
train_config.train_loader.augmentations.enabled = False
return train_config
def test_can_initialize_default_module(): def test_can_initialize_default_module():
module = build_default_module() module = build_default_module()
assert isinstance(module, L.LightningModule) assert isinstance(module, L.LightningModule)
@ -271,19 +281,7 @@ def test_train_smoke_produces_loadable_checkpoint(
sample_audio_loader: AudioLoader, sample_audio_loader: AudioLoader,
): ):
# Given # Given
train_config = TrainingConfig.model_validate( train_config = build_fast_train_config()
{
"trainer": {
"limit_train_batches": 1,
"limit_val_batches": 1,
"log_every_n_steps": 1,
},
"train_loader": {
"batch_size": 1,
"augmentations": {"enabled": False},
},
}
)
# When # When
run_train( run_train(
@ -310,6 +308,47 @@ def test_train_smoke_produces_loadable_checkpoint(
assert outputs is not None assert outputs is not None
@pytest.mark.slow
def test_run_train_compiles_detector_when_train_config_requests_compile(
tmp_path: Path,
example_annotations: list[data.ClipAnnotation],
record_detector_compilation,
) -> None:
targets_config = TargetConfig()
targets = build_targets(targets_config)
roi_mapper = build_roi_mapping(targets_config.roi)
model = build_model(
ModelConfig(),
class_names=targets.class_names,
dimension_names=roi_mapper.dimension_names,
)
train_config = build_fast_train_config()
train_config.compile_model = True
recorder = record_detector_compilation(model)
module = run_train(
train_annotations=example_annotations[:1],
val_annotations=example_annotations[:1],
model=model,
targets=targets,
roi_mapper=roi_mapper,
targets_config=targets_config,
train_config=train_config,
num_epochs=1,
train_workers=0,
val_workers=0,
checkpoint_dir=tmp_path / "checkpoints",
log_dir=tmp_path / "logs",
seed=0,
)
assert (
getattr(module.model.detector, "_compiled_call_impl", None) is not None
)
assert recorder.compile_count == 1
assert recorder.call_count > 0
def test_build_training_module_uses_provided_model() -> None: def test_build_training_module_uses_provided_model() -> None:
targets = build_targets(TargetConfig()) targets = build_targets(TargetConfig())
roi_mapper = build_roi_mapping(TargetConfig().roi) roi_mapper = build_roi_mapping(TargetConfig().roi)

4736
uv.lock generated

File diff suppressed because it is too large Load Diff