Skip to content

config

birdnet_stm32.training.config

Model configuration dataclass with JSON serialization and validation.

Replaces the raw dict config previously saved alongside checkpoints. Backward-compatible: loads legacy JSON files that lack newer fields.

ModelConfig dataclass

Immutable model configuration for training, conversion, and deployment.

Attributes:

Name Type Description
sample_rate int

Audio sample rate in Hz.

num_mels int

Number of mel frequency bins.

spec_width int

Spectrogram width in frames.

fft_length int

FFT window size.

chunk_duration float

Audio chunk duration in seconds.

hop_length int

STFT hop length in samples.

audio_frontend str

Frontend mode ('librosa', 'hybrid', 'raw').

mag_scale str

Magnitude scaling ('pwl', 'none').

input_compression str

Compression of a precomputed spectrogram input before quantization ('none', 'sqrt', 'log'); librosa/hybrid only.

raw_magnitude str

How the raw frontend combines its quadrature pair into a band envelope ('alpha_max', 'l1', 'halfwave'); raw only. The options differ in how many INT8 activation grids sit between the filterbank and the band stage.

raw_overlap int

Analysis window of the raw filterbank as a multiple of its hop; raw only. 2 is the release geometry (448 taps at 2.5 s / 256 frames); 1 halves the window, its MACs and the number of partial convolutions it is emitted as.

raw_bank str

How the raw quadrature pair is emitted: 'pair' (two banks, as every model up to 1.4) or 'fused' (one bank of 2*num_mels filters, sliced into the two components). Identical arithmetic, half the convolutions and summations; raw only.

raw_exposure_gain float

Gain of the raw filterbank's second, clamped exposure; 1 means one exposure (every release). Experimental; raw only.

raw_exposure_mode str

'channels' (both exposures to the backbone) or 'compress' (mixed per band into a knee compressor). Experimental.

output_activation str

What the converted model emits: 'sigmoid' probabilities (default) or raw 'logit' scores. A logit model leaves the sigmoid to the caller, which keeps the output off the INT8 1/256 probability grid; see docs/inference.md.

embeddings_size int

Dense embedding dimension before classifier.

alpha float

Width multiplier for channel counts.

depth_multiplier int

Block repeat count per stage.

head_pooling str

Pooling head; 'gap' is the only one.

dw_kernel_size int

Depthwise kernel size in stages 2-4 (3 or 5).

stage_widths list[int]

Base output channels of the four stages, before alpha. The default is what configs saved before the field existed were built with; train defaults to models.dscnn.STAGE_WIDTHS.

backbone str

'dscnn' (releases up to 2.1; configs saved before the field existed), 'efficientnet_b0_n6' (N6-B0, 2.2) or 'efficientnet_b0_n6w' (that with wider bottlenecks, 2.3); alpha, depth_multiplier, dw_kernel_size and stage_widths do not apply to the EfficientNet ones. train defaults to 'efficientnet_b0_n6'.

num_classes int

Number of output classes.

class_names list[str]

Ordered list of class label strings.

frontend_trainable bool

Whether frontend weights are trainable.

dropout_rate float

Dropout rate before classifier head.

Source code in birdnet_stm32/training/config.py
@dataclass
class ModelConfig:
    """Immutable model configuration for training, conversion, and deployment.

    Attributes:
        sample_rate: Audio sample rate in Hz.
        num_mels: Number of mel frequency bins.
        spec_width: Spectrogram width in frames.
        fft_length: FFT window size.
        chunk_duration: Audio chunk duration in seconds.
        hop_length: STFT hop length in samples.
        audio_frontend: Frontend mode ('librosa', 'hybrid', 'raw').
        mag_scale: Magnitude scaling ('pwl', 'none').
        input_compression: Compression of a precomputed spectrogram input
            before quantization ('none', 'sqrt', 'log'); librosa/hybrid only.
        raw_magnitude: How the raw frontend combines its quadrature pair into
            a band envelope ('alpha_max', 'l1', 'halfwave'); raw only. The
            options differ in how many INT8 activation grids sit between the
            filterbank and the band stage.
        raw_overlap: Analysis window of the raw filterbank as a multiple of
            its hop; raw only. 2 is the release geometry (448 taps at 2.5 s /
            256 frames); 1 halves the window, its MACs and the number of
            partial convolutions it is emitted as.
        raw_bank: How the raw quadrature pair is emitted: 'pair' (two banks,
            as every model up to 1.4) or 'fused' (one bank of 2*num_mels
            filters, sliced into the two components). Identical arithmetic,
            half the convolutions and summations; raw only.
        raw_exposure_gain: Gain of the raw filterbank's second, clamped exposure;
            1 means one exposure (every release). Experimental; raw only.
        raw_exposure_mode: 'channels' (both exposures to the backbone) or
            'compress' (mixed per band into a knee compressor). Experimental.
        output_activation: What the converted model emits: 'sigmoid'
            probabilities (default) or raw 'logit' scores. A logit model leaves
            the sigmoid to the caller, which keeps the output off the INT8
            1/256 probability grid; see docs/inference.md.
        embeddings_size: Dense embedding dimension before classifier.
        alpha: Width multiplier for channel counts.
        depth_multiplier: Block repeat count per stage.
        head_pooling: Pooling head; 'gap' is the only one.
        dw_kernel_size: Depthwise kernel size in stages 2-4 (3 or 5).
        stage_widths: Base output channels of the four stages, before alpha. The
            default is what configs saved before the field existed were built
            with; ``train`` defaults to ``models.dscnn.STAGE_WIDTHS``.
        backbone: 'dscnn' (releases up to 2.1; configs saved before the field
            existed), 'efficientnet_b0_n6' (N6-B0, 2.2) or 'efficientnet_b0_n6w'
            (that with wider bottlenecks, 2.3); alpha, depth_multiplier,
            dw_kernel_size and stage_widths do not apply to the EfficientNet
            ones. ``train`` defaults to 'efficientnet_b0_n6'.
        num_classes: Number of output classes.
        class_names: Ordered list of class label strings.
        frontend_trainable: Whether frontend weights are trainable.
        dropout_rate: Dropout rate before classifier head.
    """

    # Audio
    sample_rate: int = 24000
    num_mels: int = 64
    spec_width: int = 256
    fft_length: int = 512
    chunk_duration: float = 3.0
    hop_length: int = 281
    audio_frontend: str = "hybrid"
    mag_scale: str = "pwl"
    input_compression: str = "none"
    raw_magnitude: str = "alpha_max"
    raw_overlap: int = 2
    raw_bank: str = "pair"
    raw_exposure_gain: float = 1.0
    raw_exposure_mode: str = "channels"
    output_activation: str = "sigmoid"

    # Model architecture
    embeddings_size: int = 256
    alpha: float = 1.0
    depth_multiplier: int = 1
    head_pooling: str = "gap"
    dw_kernel_size: int = 3
    stage_widths: list[int] = field(default_factory=lambda: [32, 64, 128, 256])
    backbone: str = "dscnn"
    dropout_rate: float = 0.5
    frontend_trainable: bool = False

    # Classes
    num_classes: int = 0
    class_names: list[str] = field(default_factory=list)

    # -- Validation ----------------------------------------------------------

    _VALID_FRONTENDS = frozenset({"librosa", "hybrid", "raw"})
    _VALID_MAG_SCALES = frozenset({"pwl", "none"})
    _VALID_INPUT_COMPRESSIONS = frozenset({"none", "sqrt", "log"})
    _VALID_RAW_MAGNITUDES = frozenset({"alpha_max", "l1", "halfwave"})
    _VALID_RAW_BANKS = frozenset({"pair", "fused"})
    _VALID_OUTPUT_ACTIVATIONS = frozenset({"sigmoid", "logit"})
    _VALID_HEAD_POOLINGS = frozenset({"gap"})
    _VALID_DW_KERNEL_SIZES = frozenset({3, 5})
    _VALID_BACKBONES = frozenset({"dscnn", "efficientnet_b0_n6", "efficientnet_b0_n6w"})

    def __post_init__(self) -> None:
        """Validate field values after initialization."""
        if self.sample_rate <= 0:
            raise ValueError(f"sample_rate must be positive, got {self.sample_rate}")
        if self.num_mels <= 0:
            raise ValueError(f"num_mels must be positive, got {self.num_mels}")
        if self.spec_width <= 0:
            raise ValueError(f"spec_width must be positive, got {self.spec_width}")
        if self.fft_length <= 0:
            raise ValueError(f"fft_length must be positive, got {self.fft_length}")
        if self.chunk_duration <= 0:
            raise ValueError(f"chunk_duration must be positive, got {self.chunk_duration}")
        if self.audio_frontend not in self._VALID_FRONTENDS:
            raise ValueError(f"audio_frontend '{self.audio_frontend}' not in {sorted(self._VALID_FRONTENDS)}")
        if self.mag_scale not in self._VALID_MAG_SCALES:
            raise ValueError(f"mag_scale '{self.mag_scale}' not in {sorted(self._VALID_MAG_SCALES)}")
        if self.input_compression not in self._VALID_INPUT_COMPRESSIONS:
            raise ValueError(
                f"input_compression '{self.input_compression}' not in {sorted(self._VALID_INPUT_COMPRESSIONS)}"
            )
        if self.raw_magnitude not in self._VALID_RAW_MAGNITUDES:
            raise ValueError(f"raw_magnitude '{self.raw_magnitude}' not in {sorted(self._VALID_RAW_MAGNITUDES)}")
        if self.raw_magnitude != "alpha_max" and self.audio_frontend != "raw":
            raise ValueError("raw_magnitude applies to the raw frontend's quadrature pair only")
        if self.raw_overlap < 1:
            raise ValueError(f"raw_overlap must be >= 1, got {self.raw_overlap}")
        if self.raw_overlap != 2 and self.audio_frontend != "raw":
            raise ValueError("raw_overlap applies to the raw filterbank only")
        if self.raw_bank not in self._VALID_RAW_BANKS:
            raise ValueError(f"raw_bank '{self.raw_bank}' not in {sorted(self._VALID_RAW_BANKS)}")
        if self.raw_bank != "pair" and self.audio_frontend != "raw":
            raise ValueError("raw_bank applies to the raw filterbank only")
        if self.raw_exposure_gain < 1.0:
            raise ValueError(f"raw_exposure_gain must be >= 1, got {self.raw_exposure_gain}")
        if self.raw_exposure_gain > 1.0 and (self.audio_frontend != "raw" or self.raw_bank != "fused"):
            raise ValueError("raw_exposure_gain needs the raw frontend with a fused bank")
        if self.raw_exposure_mode not in ("channels", "compress"):
            raise ValueError(f"raw_exposure_mode '{self.raw_exposure_mode}' not in ['channels', 'compress']")
        if self.output_activation not in self._VALID_OUTPUT_ACTIVATIONS:
            raise ValueError(
                f"output_activation '{self.output_activation}' not in {sorted(self._VALID_OUTPUT_ACTIVATIONS)}"
            )
        if self.input_compression != "none" and self.audio_frontend == "raw":
            raise ValueError("input_compression applies to spectrogram inputs, not the raw frontend")
        if self.alpha <= 0:
            raise ValueError(f"alpha must be positive, got {self.alpha}")
        if self.depth_multiplier < 1:
            raise ValueError(f"depth_multiplier must be >= 1, got {self.depth_multiplier}")
        if self.head_pooling not in self._VALID_HEAD_POOLINGS:
            raise ValueError(f"head_pooling '{self.head_pooling}' not in {sorted(self._VALID_HEAD_POOLINGS)}")
        if self.dw_kernel_size not in self._VALID_DW_KERNEL_SIZES:
            raise ValueError(f"dw_kernel_size {self.dw_kernel_size} not in {sorted(self._VALID_DW_KERNEL_SIZES)}")
        if len(self.stage_widths) != 4 or any(int(w) <= 0 for w in self.stage_widths):
            raise ValueError(f"stage_widths must be four positive widths, got {self.stage_widths}")
        if self.backbone not in self._VALID_BACKBONES:
            raise ValueError(f"backbone '{self.backbone}' not in {sorted(self._VALID_BACKBONES)}")
        if not 0 <= self.dropout_rate < 1:
            raise ValueError(f"dropout_rate must be in [0, 1), got {self.dropout_rate}")
        if self.num_classes < 0:
            raise ValueError(f"num_classes must be >= 0, got {self.num_classes}")
        if self.class_names and len(self.class_names) != self.num_classes:
            raise ValueError(f"class_names length ({len(self.class_names)}) != num_classes ({self.num_classes})")

    # -- Serialization -------------------------------------------------------

    def to_dict(self) -> dict:
        """Convert to a plain dict suitable for JSON serialization."""
        return asdict(self)

    def save(self, path: str | Path) -> None:
        """Write config to a JSON file.

        Args:
            path: Destination file path.
        """
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(self.to_dict(), indent=2) + "\n")

    @classmethod
    def from_dict(cls, data: dict) -> ModelConfig:
        """Create a ModelConfig from a dict, ignoring unknown keys.

        Unknown keys are silently dropped so legacy configs still load.

        Args:
            data: Dictionary of config values.

        Returns:
            Validated ModelConfig instance.
        """
        valid_keys = {f.name for f in cls.__dataclass_fields__.values()}
        filtered = {k: v for k, v in data.items() if k in valid_keys}
        return cls(**filtered)

    @classmethod
    def load(cls, path: str | Path) -> ModelConfig:
        """Load config from a JSON file.

        Args:
            path: Path to JSON config file.

        Returns:
            Validated ModelConfig instance.

        Raises:
            FileNotFoundError: If path does not exist.
        """
        path = Path(path)
        data = json.loads(path.read_text())
        return cls.from_dict(data)

__post_init__()

Validate field values after initialization.

Source code in birdnet_stm32/training/config.py
def __post_init__(self) -> None:
    """Validate field values after initialization."""
    if self.sample_rate <= 0:
        raise ValueError(f"sample_rate must be positive, got {self.sample_rate}")
    if self.num_mels <= 0:
        raise ValueError(f"num_mels must be positive, got {self.num_mels}")
    if self.spec_width <= 0:
        raise ValueError(f"spec_width must be positive, got {self.spec_width}")
    if self.fft_length <= 0:
        raise ValueError(f"fft_length must be positive, got {self.fft_length}")
    if self.chunk_duration <= 0:
        raise ValueError(f"chunk_duration must be positive, got {self.chunk_duration}")
    if self.audio_frontend not in self._VALID_FRONTENDS:
        raise ValueError(f"audio_frontend '{self.audio_frontend}' not in {sorted(self._VALID_FRONTENDS)}")
    if self.mag_scale not in self._VALID_MAG_SCALES:
        raise ValueError(f"mag_scale '{self.mag_scale}' not in {sorted(self._VALID_MAG_SCALES)}")
    if self.input_compression not in self._VALID_INPUT_COMPRESSIONS:
        raise ValueError(
            f"input_compression '{self.input_compression}' not in {sorted(self._VALID_INPUT_COMPRESSIONS)}"
        )
    if self.raw_magnitude not in self._VALID_RAW_MAGNITUDES:
        raise ValueError(f"raw_magnitude '{self.raw_magnitude}' not in {sorted(self._VALID_RAW_MAGNITUDES)}")
    if self.raw_magnitude != "alpha_max" and self.audio_frontend != "raw":
        raise ValueError("raw_magnitude applies to the raw frontend's quadrature pair only")
    if self.raw_overlap < 1:
        raise ValueError(f"raw_overlap must be >= 1, got {self.raw_overlap}")
    if self.raw_overlap != 2 and self.audio_frontend != "raw":
        raise ValueError("raw_overlap applies to the raw filterbank only")
    if self.raw_bank not in self._VALID_RAW_BANKS:
        raise ValueError(f"raw_bank '{self.raw_bank}' not in {sorted(self._VALID_RAW_BANKS)}")
    if self.raw_bank != "pair" and self.audio_frontend != "raw":
        raise ValueError("raw_bank applies to the raw filterbank only")
    if self.raw_exposure_gain < 1.0:
        raise ValueError(f"raw_exposure_gain must be >= 1, got {self.raw_exposure_gain}")
    if self.raw_exposure_gain > 1.0 and (self.audio_frontend != "raw" or self.raw_bank != "fused"):
        raise ValueError("raw_exposure_gain needs the raw frontend with a fused bank")
    if self.raw_exposure_mode not in ("channels", "compress"):
        raise ValueError(f"raw_exposure_mode '{self.raw_exposure_mode}' not in ['channels', 'compress']")
    if self.output_activation not in self._VALID_OUTPUT_ACTIVATIONS:
        raise ValueError(
            f"output_activation '{self.output_activation}' not in {sorted(self._VALID_OUTPUT_ACTIVATIONS)}"
        )
    if self.input_compression != "none" and self.audio_frontend == "raw":
        raise ValueError("input_compression applies to spectrogram inputs, not the raw frontend")
    if self.alpha <= 0:
        raise ValueError(f"alpha must be positive, got {self.alpha}")
    if self.depth_multiplier < 1:
        raise ValueError(f"depth_multiplier must be >= 1, got {self.depth_multiplier}")
    if self.head_pooling not in self._VALID_HEAD_POOLINGS:
        raise ValueError(f"head_pooling '{self.head_pooling}' not in {sorted(self._VALID_HEAD_POOLINGS)}")
    if self.dw_kernel_size not in self._VALID_DW_KERNEL_SIZES:
        raise ValueError(f"dw_kernel_size {self.dw_kernel_size} not in {sorted(self._VALID_DW_KERNEL_SIZES)}")
    if len(self.stage_widths) != 4 or any(int(w) <= 0 for w in self.stage_widths):
        raise ValueError(f"stage_widths must be four positive widths, got {self.stage_widths}")
    if self.backbone not in self._VALID_BACKBONES:
        raise ValueError(f"backbone '{self.backbone}' not in {sorted(self._VALID_BACKBONES)}")
    if not 0 <= self.dropout_rate < 1:
        raise ValueError(f"dropout_rate must be in [0, 1), got {self.dropout_rate}")
    if self.num_classes < 0:
        raise ValueError(f"num_classes must be >= 0, got {self.num_classes}")
    if self.class_names and len(self.class_names) != self.num_classes:
        raise ValueError(f"class_names length ({len(self.class_names)}) != num_classes ({self.num_classes})")

to_dict()

Convert to a plain dict suitable for JSON serialization.

Source code in birdnet_stm32/training/config.py
def to_dict(self) -> dict:
    """Convert to a plain dict suitable for JSON serialization."""
    return asdict(self)

save(path)

Write config to a JSON file.

Parameters:

Name Type Description Default
path str | Path

Destination file path.

required
Source code in birdnet_stm32/training/config.py
def save(self, path: str | Path) -> None:
    """Write config to a JSON file.

    Args:
        path: Destination file path.
    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(self.to_dict(), indent=2) + "\n")

from_dict(data) classmethod

Create a ModelConfig from a dict, ignoring unknown keys.

Unknown keys are silently dropped so legacy configs still load.

Parameters:

Name Type Description Default
data dict

Dictionary of config values.

required

Returns:

Type Description
ModelConfig

Validated ModelConfig instance.

Source code in birdnet_stm32/training/config.py
@classmethod
def from_dict(cls, data: dict) -> ModelConfig:
    """Create a ModelConfig from a dict, ignoring unknown keys.

    Unknown keys are silently dropped so legacy configs still load.

    Args:
        data: Dictionary of config values.

    Returns:
        Validated ModelConfig instance.
    """
    valid_keys = {f.name for f in cls.__dataclass_fields__.values()}
    filtered = {k: v for k, v in data.items() if k in valid_keys}
    return cls(**filtered)

load(path) classmethod

Load config from a JSON file.

Parameters:

Name Type Description Default
path str | Path

Path to JSON config file.

required

Returns:

Type Description
ModelConfig

Validated ModelConfig instance.

Raises:

Type Description
FileNotFoundError

If path does not exist.

Source code in birdnet_stm32/training/config.py
@classmethod
def load(cls, path: str | Path) -> ModelConfig:
    """Load config from a JSON file.

    Args:
        path: Path to JSON config file.

    Returns:
        Validated ModelConfig instance.

    Raises:
        FileNotFoundError: If path does not exist.
    """
    path = Path(path)
    data = json.loads(path.read_text())
    return cls.from_dict(data)