Skip to content

teacher

birdnet_stm32.data.teacher

Per-window soft targets from a cached teacher model.

A teacher (a larger audio classifier) is run once, offline, over every training recording on a fixed window grid, and its scores are stored on disk. Training then looks up the teacher window that best matches each chunk and blends its scores into the chunk's label. The teacher never runs during training, so this costs nothing per step.

Why: training labels are weak. A recording carries one species label, so every chunk drawn from it trains as that species alone, whether the chunk holds the call, silence, or a different bird calling over it. A per-window teacher score says which of those it is.

Kept free of TensorFlow: the loader's worker processes import it.

Cache format (a directory):

  • meta.json: classes (must match the training class order), teacher_mask (one bool per class; False where the teacher has no corresponding output, and such classes keep their hard label), and teacher_window_s (teacher window length in seconds).
  • mapped.npy: float16 [n_windows, n_classes] teacher probabilities.
  • starts.npy: float32 [n_windows] window start, in seconds from the start of the recording.
  • index.npz: sample_id (recording file stem), row_offset and n_windows, locating each recording's rows.
  • emb.npy (optional): float16 [n_windows, emb_dim] teacher embeddings, row-aligned with mapped.npy. Needed only for embedding distillation.

The large arrays are memory-mapped, so every worker shares one copy through the page cache.

TeacherTargets

Look up cached teacher scores for a chunk of a training recording.

Parameters:

Name Type Description Default
cache_dir str | Path

Directory in the format described in the module docstring.

required
classes list[str]

The training class order. Must equal the cache's, so a cache built for another schema cannot silently mislabel classes.

required
embeddings bool

Also memory-map emb.npy, for :meth:embedding.

False
Source code in birdnet_stm32/data/teacher.py
class TeacherTargets:
    """Look up cached teacher scores for a chunk of a training recording.

    Args:
        cache_dir: Directory in the format described in the module docstring.
        classes: The training class order. Must equal the cache's, so a cache
            built for another schema cannot silently mislabel classes.
        embeddings: Also memory-map ``emb.npy``, for :meth:`embedding`.
    """

    def __init__(self, cache_dir: str | Path, classes: list[str], embeddings: bool = False):
        root = Path(cache_dir)
        meta = json.loads((root / "meta.json").read_text())
        if list(meta["classes"]) != list(classes):
            raise ValueError(
                f"teacher cache {root} was built for a different class list "
                f"({len(meta['classes'])} classes) than training ({len(classes)})"
            )
        self.mask = np.asarray(meta["teacher_mask"], dtype=bool)
        if self.mask.shape != (len(classes),):
            raise ValueError(f"teacher_mask has shape {self.mask.shape}, expected ({len(classes)},)")
        self.window_s = float(meta["teacher_window_s"])
        self.mapped = _random_access(np.load(root / "mapped.npy", mmap_mode="r"))
        self.starts = _random_access(np.load(root / "starts.npy", mmap_mode="r"))
        self.emb: np.ndarray | None = None
        if embeddings:
            if not (root / "emb.npy").is_file():
                raise FileNotFoundError(f"teacher cache {root} has no emb.npy (build it with embeddings)")
            self.emb = _random_access(np.load(root / "emb.npy", mmap_mode="r"))
            if self.emb.shape[0] != self.mapped.shape[0]:
                raise ValueError(f"emb.npy has {self.emb.shape[0]} rows, mapped.npy {self.mapped.shape[0]}")
        index = np.load(root / "index.npz")
        self.index: dict[str, tuple[int, int]] = {
            str(sid): (int(off), int(n))
            for sid, off, n in zip(index["sample_id"], index["row_offset"], index["n_windows"], strict=True)
        }

    def __contains__(self, sample_id: str) -> bool:
        return sample_id in self.index

    @property
    def embedding_dim(self) -> int:
        """Width of the teacher embedding (0 when embeddings are not loaded)."""
        return 0 if self.emb is None else int(self.emb.shape[1])

    def _nearest(self, sample_id: str, chunk_start_s: float, chunk_duration_s: float) -> int | None:
        """Cache row of the window whose centre is nearest the chunk's, or ``None``.

        ``None`` when the recording is not cached or no window centre lies
        within one window length of the chunk's centre. Windows sit on a 1.25 s
        grid, so the nearest 3 s window still covers most of a 2.5 s chunk.
        """
        entry = self.index.get(sample_id)
        if entry is None:
            return None
        offset, n = entry
        if n <= 0:
            return None
        centres = np.asarray(self.starts[offset : offset + n], dtype=np.float64) + self.window_s / 2.0
        chunk_centre = chunk_start_s + chunk_duration_s / 2.0
        distance = np.abs(centres - chunk_centre)
        i = int(np.argmin(distance))
        if distance[i] > self.window_s:
            return None
        return offset + i

    def lookup(self, sample_id: str, chunk_start_s: float, chunk_duration_s: float) -> np.ndarray | None:
        """Teacher scores for the window whose centre is nearest the chunk's.

        Args:
            sample_id: Recording identifier (the file stem).
            chunk_start_s: Where the chunk starts in the recording, in seconds.
            chunk_duration_s: Chunk length in seconds.

        Returns:
            float32 ``[n_classes]`` teacher scores, or ``None`` when the
            recording is not in the cache, no teacher window lies within one
            window length of the chunk, or the teacher's scores for that window
            are not finite. Callers fall back to the hard label.

        A non-finite row means the teacher's output was undefined, not that it
        heard nothing: BirdNET+ V3.0 returns NaN on digital silence (the muted
        stretches of edited recordings), where its input normalization divides
        by zero. Reading that as "no species present" would be wrong, and a
        single NaN target is enough to turn every weight non-finite.
        """
        row = self._nearest(sample_id, chunk_start_s, chunk_duration_s)
        if row is None:
            return None
        scores = np.asarray(self.mapped[row], dtype=np.float32)
        if not np.isfinite(scores).all():
            return None
        return scores

    def embedding(self, sample_id: str, chunk_start_s: float, chunk_duration_s: float) -> np.ndarray | None:
        """Teacher embedding for the window nearest the chunk, as :meth:`lookup`.

        Returns:
            float32 ``[embedding_dim]``, or ``None`` under the same conditions as
            :meth:`lookup` (a non-finite row included); the caller then leaves
            the chunk out of the embedding loss.

        Raises:
            RuntimeError: If the cache was opened without ``embeddings=True``.
        """
        if self.emb is None:
            raise RuntimeError("teacher embeddings were not loaded (embeddings=False)")
        row = self._nearest(sample_id, chunk_start_s, chunk_duration_s)
        if row is None:
            return None
        vector = np.asarray(self.emb[row], dtype=np.float32)
        if not np.isfinite(vector).all():
            return None
        return vector

embedding_dim property

Width of the teacher embedding (0 when embeddings are not loaded).

lookup(sample_id, chunk_start_s, chunk_duration_s)

Teacher scores for the window whose centre is nearest the chunk's.

Parameters:

Name Type Description Default
sample_id str

Recording identifier (the file stem).

required
chunk_start_s float

Where the chunk starts in the recording, in seconds.

required
chunk_duration_s float

Chunk length in seconds.

required

Returns:

Type Description
ndarray | None

float32 [n_classes] teacher scores, or None when the

ndarray | None

recording is not in the cache, no teacher window lies within one

ndarray | None

window length of the chunk, or the teacher's scores for that window

ndarray | None

are not finite. Callers fall back to the hard label.

A non-finite row means the teacher's output was undefined, not that it heard nothing: BirdNET+ V3.0 returns NaN on digital silence (the muted stretches of edited recordings), where its input normalization divides by zero. Reading that as "no species present" would be wrong, and a single NaN target is enough to turn every weight non-finite.

Source code in birdnet_stm32/data/teacher.py
def lookup(self, sample_id: str, chunk_start_s: float, chunk_duration_s: float) -> np.ndarray | None:
    """Teacher scores for the window whose centre is nearest the chunk's.

    Args:
        sample_id: Recording identifier (the file stem).
        chunk_start_s: Where the chunk starts in the recording, in seconds.
        chunk_duration_s: Chunk length in seconds.

    Returns:
        float32 ``[n_classes]`` teacher scores, or ``None`` when the
        recording is not in the cache, no teacher window lies within one
        window length of the chunk, or the teacher's scores for that window
        are not finite. Callers fall back to the hard label.

    A non-finite row means the teacher's output was undefined, not that it
    heard nothing: BirdNET+ V3.0 returns NaN on digital silence (the muted
    stretches of edited recordings), where its input normalization divides
    by zero. Reading that as "no species present" would be wrong, and a
    single NaN target is enough to turn every weight non-finite.
    """
    row = self._nearest(sample_id, chunk_start_s, chunk_duration_s)
    if row is None:
        return None
    scores = np.asarray(self.mapped[row], dtype=np.float32)
    if not np.isfinite(scores).all():
        return None
    return scores

embedding(sample_id, chunk_start_s, chunk_duration_s)

Teacher embedding for the window nearest the chunk, as :meth:lookup.

Returns:

Type Description
ndarray | None

float32 [embedding_dim], or None under the same conditions as

ndarray | None

meth:lookup (a non-finite row included); the caller then leaves

ndarray | None

the chunk out of the embedding loss.

Raises:

Type Description
RuntimeError

If the cache was opened without embeddings=True.

Source code in birdnet_stm32/data/teacher.py
def embedding(self, sample_id: str, chunk_start_s: float, chunk_duration_s: float) -> np.ndarray | None:
    """Teacher embedding for the window nearest the chunk, as :meth:`lookup`.

    Returns:
        float32 ``[embedding_dim]``, or ``None`` under the same conditions as
        :meth:`lookup` (a non-finite row included); the caller then leaves
        the chunk out of the embedding loss.

    Raises:
        RuntimeError: If the cache was opened without ``embeddings=True``.
    """
    if self.emb is None:
        raise RuntimeError("teacher embeddings were not loaded (embeddings=False)")
    row = self._nearest(sample_id, chunk_start_s, chunk_duration_s)
    if row is None:
        return None
    vector = np.asarray(self.emb[row], dtype=np.float32)
    if not np.isfinite(vector).all():
        return None
    return vector

blend_targets(hard, teacher, mask, weight, background=False)

Mix a hard label with teacher scores on the classes the teacher covers.

(1 - weight) * hard + weight * teacher where mask is set, and the hard label unchanged elsewhere. A non-finite teacher entry also keeps the hard label (np.clip passes NaN through, so clipping alone would not catch it). Every entry stays in [0, 1], so the result is a valid soft target for binary cross-entropy.

With weight 0.5, a labelled class the teacher also hears stays near 1, a labelled class on a chunk where the teacher hears nothing drops to 0.5, and an unlabelled species the teacher hears clearly rises toward 0.5.

Parameters:

Name Type Description Default
hard ndarray

float32 [n_classes] hard label (0/1, or all zero for noise).

required
teacher ndarray

float32 [n_classes] teacher scores.

required
mask ndarray

bool [n_classes], where the teacher has a corresponding output.

required
weight float

Teacher share in [0, 1]. 0 returns the hard label.

required
background bool

Where the hard label is 0 (a species the file does not name), use the teacher's score itself from BACKGROUND_FLOOR and 1 from BACKGROUND_PROMOTE rather than the blend.

False

Returns:

Type Description
ndarray

float32 [n_classes] blended target.

Source code in birdnet_stm32/data/teacher.py
def blend_targets(
    hard: np.ndarray, teacher: np.ndarray, mask: np.ndarray, weight: float, background: bool = False
) -> np.ndarray:
    """Mix a hard label with teacher scores on the classes the teacher covers.

    ``(1 - weight) * hard + weight * teacher`` where ``mask`` is set, and the
    hard label unchanged elsewhere. A non-finite teacher entry also keeps the
    hard label (``np.clip`` passes NaN through, so clipping alone would not
    catch it). Every entry stays in ``[0, 1]``, so the result is a valid soft
    target for binary cross-entropy.

    With ``weight`` 0.5, a labelled class the teacher also hears stays near 1,
    a labelled class on a chunk where the teacher hears nothing drops to 0.5,
    and an unlabelled species the teacher hears clearly rises toward 0.5.

    Args:
        hard: float32 ``[n_classes]`` hard label (0/1, or all zero for noise).
        teacher: float32 ``[n_classes]`` teacher scores.
        mask: bool ``[n_classes]``, where the teacher has a corresponding output.
        weight: Teacher share in ``[0, 1]``. 0 returns the hard label.
        background: Where the hard label is 0 (a species the file does not
            name), use the teacher's score itself from ``BACKGROUND_FLOOR``
            and 1 from ``BACKGROUND_PROMOTE`` rather than the blend.

    Returns:
        float32 ``[n_classes]`` blended target.
    """
    if not 0.0 <= weight <= 1.0:
        raise ValueError(f"teacher weight must be in [0, 1], got {weight}")
    out = np.asarray(hard, dtype=np.float32).copy()
    t = np.asarray(teacher, dtype=np.float32)
    use = np.asarray(mask, dtype=bool) & np.isfinite(t)
    t = np.clip(t, 0.0, 1.0)
    unlabelled = use & (out <= 0.0)
    out[use] = (1.0 - weight) * out[use] + weight * t[use]
    if background and weight > 0:
        own = unlabelled & (t >= BACKGROUND_FLOOR)
        out[own] = t[own]
        out[unlabelled & (t >= BACKGROUND_PROMOTE)] = 1.0
    return out