From e613fe57796a33e7d8c63951eb1fd6c58904f834 Mon Sep 17 00:00:00 2001 From: Max Mauermann Date: Mon, 27 Jul 2026 13:36:58 +0200 Subject: [PATCH 1/4] random splits for training data operate with indices now instead of working with arrays --- birdnet_analyzer/model.py | 104 ++++++++++++++------------------------ tests/train/test_model.py | 56 ++++++++++++++++++++ 2 files changed, 95 insertions(+), 65 deletions(-) create mode 100644 tests/train/test_model.py diff --git a/birdnet_analyzer/model.py b/birdnet_analyzer/model.py index 0f2bac7b3..76caa7960 100644 --- a/birdnet_analyzer/model.py +++ b/birdnet_analyzer/model.py @@ -151,11 +151,10 @@ def random_split(x, y, rng: Generator, val_ratio=0.2): A tuple of (x_train, y_train, x_val, y_val). """ num_classes = y.shape[1] - x_train, y_train, x_val, y_val = [], [], [], [] + train_indices, val_indices = [], [] for i in range(num_classes): positive_indices = np.where(y[:, i] == 1)[0] - negative_indices = np.where(y[:, i] == -1)[0] num_samples = len(positive_indices) num_samples_train = max(1, int(num_samples * (1 - val_ratio))) @@ -163,18 +162,18 @@ def random_split(x, y, rng: Generator, val_ratio=0.2): rng.shuffle(positive_indices) - train_indices = positive_indices[:num_samples_train] - val_indices = positive_indices[ + class_train_indices = positive_indices[:num_samples_train] + class_val_indices = positive_indices[ num_samples_train : num_samples_train + num_samples_val ] - x_train.append(x[train_indices]) - y_train.append(y[train_indices]) - x_val.append(x[val_indices]) - y_val.append(y[val_indices]) + train_indices.append(class_train_indices) + val_indices.append(class_val_indices) - x_train.append(x[negative_indices]) - y_train.append(y[negative_indices]) + # Negative samples are not class-specific in single-label training. Appending + # them in the loop above duplicates every negative sample once per class. + negative_indices = np.unique(np.where(y == -1)[0]) + train_indices.append(negative_indices) non_event_indices = np.where(np.sum(y[:, :], axis=1) == 0)[0] num_samples = len(non_event_indices) @@ -183,36 +182,21 @@ def random_split(x, y, rng: Generator, val_ratio=0.2): rng.shuffle(non_event_indices) - train_indices = non_event_indices[:num_samples_train] - val_indices = non_event_indices[ + non_event_train_indices = non_event_indices[:num_samples_train] + non_event_val_indices = non_event_indices[ num_samples_train : num_samples_train + num_samples_val ] - x_train.append(x[train_indices]) - y_train.append(y[train_indices]) - x_val.append(x[val_indices]) - y_val.append(y[val_indices]) + train_indices.append(non_event_train_indices) + val_indices.append(non_event_val_indices) - x_train = np.concatenate(x_train) - y_train = np.concatenate(y_train) - x_val = np.concatenate(x_val) - y_val = np.concatenate(y_val) + train_indices = np.concatenate(train_indices) + val_indices = np.concatenate(val_indices) - indices = np.arange(len(x_train)) + rng.shuffle(train_indices) + rng.shuffle(val_indices) - rng.shuffle(indices) - - x_train = x_train[indices] - y_train = y_train[indices] - - indices = np.arange(len(x_val)) - - rng.shuffle(indices) - - x_val = x_val[indices] - y_val = y_val[indices] - - return x_train, y_train, x_val, y_val + return x[train_indices], y[train_indices], x[val_indices], y[val_indices] def random_multilabel_split(x, y, rng: Generator, val_ratio=0.2): @@ -230,15 +214,16 @@ def random_multilabel_split(x, y, rng: Generator, val_ratio=0.2): A tuple of (x_train, y_train, x_val, y_val). """ - class_combinations = np.unique(y, axis=0) - x_train, y_train, x_val, y_val = [], [], [], [] + class_combinations, combination_ids = np.unique( + y, axis=0, return_inverse=True + ) + train_indices, val_indices = [], [] - for class_combination in class_combinations: - indices = np.where((y == class_combination).all(axis=1))[0] + for combination_id, class_combination in enumerate(class_combinations): + indices = np.flatnonzero(combination_ids == combination_id) if -1 in class_combination: - x_train.append(x[indices]) - y_train.append(y[indices]) + train_indices.append(indices) else: num_samples = len(indices) num_samples_train = max(1, int(num_samples * (1 - val_ratio))) @@ -246,32 +231,20 @@ def random_multilabel_split(x, y, rng: Generator, val_ratio=0.2): rng.shuffle(indices) - train_indices = indices[:num_samples_train] - val_indices = indices[ + combination_train_indices = indices[:num_samples_train] + combination_val_indices = indices[ num_samples_train : num_samples_train + num_samples_val ] - x_train.append(x[train_indices]) - y_train.append(y[train_indices]) - x_val.append(x[val_indices]) - y_val.append(y[val_indices]) - - x_train = np.concatenate(x_train) - y_train = np.concatenate(y_train) - x_val = np.concatenate(x_val) - y_val = np.concatenate(y_val) - - indices = np.arange(len(x_train)) - rng.shuffle(indices) - x_train = x_train[indices] - y_train = y_train[indices] + train_indices.append(combination_train_indices) + val_indices.append(combination_val_indices) - indices = np.arange(len(x_val)) - rng.shuffle(indices) - x_val = x_val[indices] - y_val = y_val[indices] + train_indices = np.concatenate(train_indices) + val_indices = np.concatenate(val_indices) + rng.shuffle(train_indices) + rng.shuffle(val_indices) - return x_train, y_train, x_val, y_val + return x[train_indices], y[train_indices], x[val_indices], y[val_indices] def upsample_core( @@ -540,10 +513,6 @@ def on_epoch_end(self, epoch, logs=None): self.on_epoch_end_fn(epoch, logs) rng = np.random.default_rng(RANDOM_SEED) - idx = np.arange(x_train.shape[0]) - rng.shuffle(idx) - x_train = x_train[idx] - y_train = y_train[idx] if val_split > 0: if not is_multi_label: @@ -554,6 +523,11 @@ def on_epoch_end(self, epoch, logs=None): x_train, y_train, x_val, y_val = random_multilabel_split( x_train, y_train, rng, val_split ) + else: + idx = np.arange(x_train.shape[0]) + rng.shuffle(idx) + x_train = x_train[idx] + y_train = y_train[idx] if upsampling_ratio > 0: x_train, y_train = upsampling( diff --git a/tests/train/test_model.py b/tests/train/test_model.py new file mode 100644 index 000000000..4a35ead87 --- /dev/null +++ b/tests/train/test_model.py @@ -0,0 +1,56 @@ +import numpy as np + +from birdnet_analyzer.model import random_multilabel_split, random_split + + +def test_random_split_adds_negative_samples_only_once(): + x = np.arange(10).reshape(-1, 1) + y = np.array( + [ + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [-1, -1, -1], + [-1, -1, -1], + [-1, -1, -1], + [-1, -1, -1], + ] + ) + + x_train, _, x_val, _ = random_split( + x, y, np.random.default_rng(42), val_ratio=0.5 + ) + + assert len(x_train) + len(x_val) == len(x) + assert len(np.unique(x_train)) == len(x_train) + assert not np.intersect1d(x_train, x_val).size + + +def test_random_multilabel_split_keeps_negative_combinations_in_training(): + x = np.arange(10).reshape(-1, 1) + y = np.array( + [ + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 1, 0], + [1, 1, 0], + [1, 1, 0], + [0, -1, 0], + [0, -1, 0], + [0, 0, 0], + [0, 0, 0], + ] + ) + + x_train, _, x_val, y_val = random_multilabel_split( + x, y, np.random.default_rng(42), val_ratio=0.5 + ) + + assert len(x_train) + len(x_val) == len(x) + assert not np.intersect1d(x_train, x_val).size + assert {6, 7}.issubset(set(x_train[:, 0])) + assert not np.any(y_val == -1) From 4b01d3ba11a52652805f20bc175ead667d94e3eb Mon Sep 17 00:00:00 2001 From: Max Mauermann Date: Mon, 27 Jul 2026 17:07:39 +0200 Subject: [PATCH 2/4] optimizations for upsampling --- birdnet_analyzer/model.py | 122 +++++++++++++++++++++----------------- birdnet_analyzer/utils.py | 2 + tests/train/test_model.py | 47 ++++++++++++++- 3 files changed, 114 insertions(+), 57 deletions(-) diff --git a/birdnet_analyzer/model.py b/birdnet_analyzer/model.py index 76caa7960..d73413f5a 100644 --- a/birdnet_analyzer/model.py +++ b/birdnet_analyzer/model.py @@ -276,33 +276,33 @@ def upsample_core( x_temp = [] if is_binary: - minority_label = 1 if y.sum(axis=0) < len(y) - y.sum(axis=0) else 0 + positive_count = y.sum(axis=0) + minority_label = 1 if positive_count < len(y) - positive_count else 0 + source_indices = np.flatnonzero(y == minority_label) + missing_samples = min_samples - len(source_indices) - while np.where(y == minority_label)[0].shape[0] + len(y_temp) < min_samples: - random_index = rng.choice(np.where(y == minority_label)[0], size=size) - x_app, y_app = apply(x, y, random_index) + for _ in range(max(0, missing_samples)): + random_index = rng.choice(source_indices, size=size) + x_app, y_app = apply(x, y, random_index, source_indices) y_temp.append(y_app) x_temp.append(x_app) else: for i in range(y.shape[1]): - class_x_temp = [] - class_y_temp = [] + source_indices = np.flatnonzero(y[:, i] == 1) + missing_samples = min_samples - len(source_indices) - while y[:, i].sum() + len(class_y_temp) < min_samples: - try: - random_index = rng.choice(np.where(y[:, i] == 1)[0], size=size) - except ValueError as e: - raise get_empty_class_exception()(index=i) from e + if missing_samples <= 0: + continue - # Apply - x_app, y_app = apply(x, y, random_index) - class_y_temp.append(y_app) - class_x_temp.append(x_app) + if not len(source_indices): + raise get_empty_class_exception()(index=i) - if len(class_y_temp) > 0: - x_temp.extend(class_x_temp) - y_temp.extend(class_y_temp) + for _ in range(missing_samples): + random_index = rng.choice(source_indices, size=size) + x_app, y_app = apply(x, y, random_index, source_indices) + y_temp.append(y_app) + x_temp.append(x_app) return x_temp, y_temp @@ -339,43 +339,60 @@ def upsampling( x_temp = [] y_temp = [] - if mode == "repeat": - - def applyRepeat(x, y, random_index): - return x[random_index[0]], y[random_index[0]] - - x_temp, y_temp = upsample_core( - x, y, min_samples, rng, applyRepeat, is_binary, size=1 - ) - - elif mode == "mean": - - def applyMean(x, y, random_indices): - mean = np.mean(x[random_indices], axis=0) + if mode in {"repeat", "mean", "linear"}: + if is_binary: + positive_count = y.sum(axis=0) + minority_label = 1 if positive_count < len(y) - positive_count else 0 + source_groups = [(None, np.flatnonzero(y == minority_label))] + else: + source_groups = [ + (class_index, np.flatnonzero(y[:, class_index] == 1)) + for class_index in range(y.shape[1]) + ] - return mean, y[random_indices[0]] + sample_size = 1 if mode == "repeat" else 2 + for class_index, source_indices in source_groups: + missing_samples = min_samples - len(source_indices) + if missing_samples <= 0: + continue - x_temp, y_temp = upsample_core(x, y, min_samples, rng, applyMean, is_binary) - elif mode == "linear": + if not len(source_indices): + if class_index is None: + raise ValueError("The minority class is empty.") + raise get_empty_class_exception()(index=class_index) - def applyLinearCombination(x, y, random_indices): - alpha = rng.uniform(0, 1) - new_sample = ( - alpha * x[random_indices[0]] + (1 - alpha) * x[random_indices[1]] + sampled_indices = rng.choice( + source_indices, size=(missing_samples, sample_size) ) + x_sources = x[sampled_indices] - return new_sample, y[random_indices[0]] + if mode == "repeat": + x_temp.append(x_sources[:, 0]) + elif mode == "mean": + x_temp.append(np.mean(x_sources, axis=1)) + else: + alpha = rng.uniform(0, 1, size=(missing_samples, 1)) + x_temp.append( + alpha * x_sources[:, 0] + (1 - alpha) * x_sources[:, 1] + ) - x_temp, y_temp = upsample_core( - x, y, min_samples, rng, applyLinearCombination, is_binary - ) + y_temp.append(y[sampled_indices[:, 0]]) elif mode == "smote": - def applySmote(x, y, random_index, k=5): - distances = np.sqrt(np.sum((x - x[random_index[0]]) ** 2, axis=1)) - indices = np.argsort(distances)[1 : k + 1] - random_neighbor = rng.choice(indices) + def applySmote(x, y, random_index, source_indices, k=5): + source_index = random_index[0] + neighbor_indices = source_indices[source_indices != source_index] + if not len(neighbor_indices): + return x[source_index], y[source_index] + + differences = x[neighbor_indices] - x[source_index] + distances = np.einsum("ij,ij->i", differences, differences) + nearest_count = min(k, len(neighbor_indices)) + nearest_indices = neighbor_indices[ + np.argpartition(distances, nearest_count - 1)[:nearest_count] + ] + random_neighbor = rng.choice(nearest_indices) diff = x[random_neighbor] - x[random_index[0]] weight = rng.uniform(0, 1) new_sample = x[random_index[0]] + weight * diff @@ -387,16 +404,8 @@ def applySmote(x, y, random_index, k=5): ) if len(x_temp) > 0: - x = np.vstack((x, np.array(x_temp))) - y = np.vstack((y, np.array(y_temp))) - - indices = np.arange(len(x)) - rng.shuffle(indices) - x = x[indices] - y = y[indices] - - del x_temp - del y_temp + x = np.vstack((x, *x_temp)) + y = np.vstack((y, *y_temp)) return x, y @@ -608,6 +617,7 @@ def _focal_loss(y_true, y_pred): batch_size=batch_size, validation_data=(x_val, y_val), callbacks=callbacks, + shuffle=True, ) os.environ["CUDA_VISIBLE_DEVICES"] = setting_cache diff --git a/birdnet_analyzer/utils.py b/birdnet_analyzer/utils.py index afc5e31a7..5fda5b225 100644 --- a/birdnet_analyzer/utils.py +++ b/birdnet_analyzer/utils.py @@ -347,6 +347,8 @@ def save_params_file(file_path, params: dict): """ import csv + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", newline="", encoding="utf-8-sig") as paramsfile: paramswriter = csv.writer(paramsfile) paramswriter.writerow(("Parameter", "Value")) diff --git a/tests/train/test_model.py b/tests/train/test_model.py index 4a35ead87..8bb940eb9 100644 --- a/tests/train/test_model.py +++ b/tests/train/test_model.py @@ -1,6 +1,12 @@ import numpy as np +import pytest -from birdnet_analyzer.model import random_multilabel_split, random_split +from birdnet_analyzer.model import ( + get_empty_class_exception, + random_multilabel_split, + random_split, + upsampling, +) def test_random_split_adds_negative_samples_only_once(): @@ -54,3 +60,42 @@ def test_random_multilabel_split_keeps_negative_combinations_in_training(): assert not np.intersect1d(x_train, x_val).size assert {6, 7}.issubset(set(x_train[:, 0])) assert not np.any(y_val == -1) + + +@pytest.mark.parametrize("mode", ["repeat", "mean", "linear"]) +def test_upsampling_balances_each_class_to_requested_ratio(mode): + x = np.arange(12, dtype="float32").reshape(6, 2) + y = np.array([[1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 1]]) + + x_upsampled, y_upsampled = upsampling( + x, y, np.random.default_rng(42), is_binary=False, ratio=1.0, mode=mode + ) + + assert len(x_upsampled) == 10 + assert np.array_equal(y_upsampled.sum(axis=0), [5, 5]) + + +@pytest.mark.parametrize("mode", ["repeat", "mean", "linear"]) +def test_upsampling_rejects_empty_classes(mode): + x = np.arange(8, dtype="float32").reshape(4, 2) + y = np.array([[1, 0], [1, 0], [1, 0], [1, 0]]) + + with pytest.raises(get_empty_class_exception()) as error: + upsampling( + x, y, np.random.default_rng(42), is_binary=False, ratio=1.0, mode=mode + ) + + assert error.value.index == 1 + + +def test_smote_only_interpolates_between_samples_of_the_same_class(): + x = np.array([[0.0], [1.0], [100.0], [101.0], [102.0]]) + y = np.array([[1, 0], [1, 0], [0, 1], [0, 1], [0, 1]]) + + x_upsampled, y_upsampled = upsampling( + x, y, np.random.default_rng(42), is_binary=False, ratio=1.0, mode="smote" + ) + + assert len(x_upsampled) == 6 + assert np.array_equal(y_upsampled.sum(axis=0), [3, 3]) + assert 0 <= x_upsampled[-1, 0] <= 1 From 7753dfa59c3e41a0f901ac859beb7d718cb5f838 Mon Sep 17 00:00:00 2001 From: Max Mauermann Date: Tue, 28 Jul 2026 12:32:56 +0200 Subject: [PATCH 3/4] Check for empty input folders before data-loading starts --- birdnet_analyzer/train/utils.py | 25 +++++++++++++++++++++++++ tests/train/test_train_loading.py | 19 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/birdnet_analyzer/train/utils.py b/birdnet_analyzer/train/utils.py index 0201aa7ff..2c547ac61 100644 --- a/birdnet_analyzer/train/utils.py +++ b/birdnet_analyzer/train/utils.py @@ -158,6 +158,29 @@ def _read_and_crop_file( return sig_splits, labels +def _check_input_folders(audio_input: str, train_folders: list[str]): + """Reject training folders without supported audio files before model setup.""" + empty_folders = [] + + for folder in train_folders: + folder_path = os.path.join(audio_input, folder) + has_audio_file = any( + entry.is_file() + and not entry.name.startswith(".") + and entry.name.rsplit(".", 1)[-1].lower() in ALLOWED_FILETYPES + for entry in os.scandir(folder_path) + ) + + if not has_audio_file: + empty_folders.append(folder) + + if empty_folders: + raise ValueError( + "The following training data folders do not contain any supported audio " + f"files: {', '.join(empty_folders)}" + ) + + def _load_training_data( audio_input: str, test_data: str | None = None, @@ -248,6 +271,8 @@ def _load_training_data( "validation-only-repeat-upsampling-for-multi-label", ) + _check_input_folders(audio_input, train_folders) + x_train, y_train, x_test, y_test = [], [], [], [] model = load("acoustic", "2.4", "tf") model_sr = int(model.get_sample_rate()) diff --git a/tests/train/test_train_loading.py b/tests/train/test_train_loading.py index 865e2c83e..5c6b1d9bb 100644 --- a/tests/train/test_train_loading.py +++ b/tests/train/test_train_loading.py @@ -14,13 +14,14 @@ import os import tempfile +from unittest.mock import patch import numpy as np import pytest import soundfile as sf from birdnet_analyzer import model_utils -from birdnet_analyzer.train.utils import _read_and_crop_file +from birdnet_analyzer.train.utils import _load_training_data, _read_and_crop_file SR = 48000 SIG_LENGTH = 3.0 @@ -81,6 +82,22 @@ def test_read_and_crop_bad_file_returns_empty(): assert labels == [] +def test_load_training_data_lists_empty_folders_before_loading_model(tmp_path): + (tmp_path / "blackbird").mkdir() + (tmp_path / "bluebird").mkdir() + robin_dir = tmp_path / "robin" + robin_dir.mkdir() + (robin_dir / "recording.wav").touch() + + with patch("birdnet_analyzer.train.utils.load") as mock_load, pytest.raises( + ValueError, match=r"blackbird.*bluebird" + ) as error: + _load_training_data(str(tmp_path)) + + assert "robin" not in str(error.value) + mock_load.assert_not_called() + + @pytest.fixture def short_wav(): """A 0.4 s wav -> shorter than the 1 s min_len, so split_signal yields nothing.""" From e84b20388733a4e231b96af89dbc118757ddf376 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 3 Aug 2026 18:22:55 +0200 Subject: [PATCH 4/4] updated error msgs --- birdnet_analyzer/gui/train.py | 9 ++++++++- birdnet_analyzer/lang/de.json | 6 +++++- birdnet_analyzer/lang/en.json | 6 +++++- birdnet_analyzer/lang/fi.json | 6 +++++- birdnet_analyzer/lang/fr.json | 6 +++++- birdnet_analyzer/lang/id.json | 6 +++++- birdnet_analyzer/lang/pt-br.json | 6 +++++- birdnet_analyzer/lang/ru.json | 6 +++++- birdnet_analyzer/lang/se.json | 6 +++++- birdnet_analyzer/lang/tlh.json | 6 +++++- birdnet_analyzer/lang/zh_TW.json | 6 +++++- birdnet_analyzer/model.py | 6 +++--- birdnet_analyzer/train/utils.py | 7 +++++-- tests/train/test_train_loading.py | 1 + 14 files changed, 67 insertions(+), 16 deletions(-) diff --git a/birdnet_analyzer/gui/train.py b/birdnet_analyzer/gui/train.py index a89b14f62..0395f436e 100644 --- a/birdnet_analyzer/gui/train.py +++ b/birdnet_analyzer/gui/train.py @@ -226,7 +226,14 @@ def trial_progression(trial): ) except Exception as e: if e.args and len(e.args) > 1: - raise gr.Error(loc.localize(e.args[1])) from e + message = loc.localize(e.args[1]) + + # Args beyond the localization key are payload for the message's + # placeholders (e.g. the list of offending folders). + if len(e.args) > 2: + message = message.format(*e.args[2:]) + + raise gr.Error(message) from e raise gr.Error(f"{e}") from e diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index 5ccaf6b6c..69eb84301 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Der \"Anhängen\"-Modus wird für den abgekoppelten Klassifikator nicht angewendet.", "validation-max-confidence-lower-than-min-confidence": "Maximaler Konfidenz-Schwellwert muss größer sein als der minimale Schwellwert.", "validation-no-audio-directory-selected": "Kein Audioverzeichnis ausgewählt", + "validation-no-audio-files-in-training-folders": "Die folgenden Trainingsdaten-Ordner enthalten keine unterstützten Audiodateien: {0}", "validation-no-cache-file-selected": "Bitte wählen Sie eine Cache-Datei aus.", "validation-no-custom-classifier-selected": "Kein benutzerdefinierter Klassifikator ausgewählt.", "validation-no-directory-for-classifier-selected": "Bitte wählen Sie ein Verzeichnis für den Klassifikator.", "validation-no-directory-selected": "Bitte wählen Sie ein Verzeichnis.", "validation-no-file-selected": "Bitte wählen Sie eine Datei aus.", + "validation-no-negative-samples-in-binary-classification": "Negative Labels können bei binärer Klassifikation nicht verwendet werden", "validation-no-species-list-selected": "Bitte wählen Sie eine Artenliste.", "validation-no-training-data-selected": "Bitte wählen Sie Ihre Trainingsdaten.", "validation-no-valid-batch-size": "Bitte geben Sie eine gültige Batch-Größe an.", "validation-no-valid-classifier-name": "Bitte geben Sie einen gültigen Namen für den Klassifikator an.", "validation-no-valid-epoch-number": "Bitte geben Sie eine gültige Anzahl von Epochen an.", "validation-no-valid-frequency": "Bitte geben Sie eine gültige Frequenz an", - "validation-no-valid-learning-rate": "Bitte geben Sie eine gültige Lernrate an." + "validation-no-valid-learning-rate": "Bitte geben Sie eine gültige Lernrate an.", + "validation-non-event-samples-required-in-binary-classification": "Für die binäre Klassifikation sind Non-Event-Beispiele erforderlich", + "validation-only-repeat-upsampling-for-multi-label": "Für Multi-Label-Training ist nur Wiederholen-Upsampling verfügbar" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 3f5bb22fb..7a000919a 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Append mode will not apply to the detached classifier.", "validation-max-confidence-lower-than-min-confidence": "Maximum confidence must be greater than minimum confidence", "validation-no-audio-directory-selected": "No audio directory selected", + "validation-no-audio-files-in-training-folders": "The following training data folders do not contain any supported audio files: {0}", "validation-no-cache-file-selected": "Please select a cache file.", "validation-no-custom-classifier-selected": "No custom classifier selected.", "validation-no-directory-for-classifier-selected": "Please select a directory for the classifier.", "validation-no-directory-selected": "Please select a directory.", "validation-no-file-selected": "Please select a file.", + "validation-no-negative-samples-in-binary-classification": "Negative labels can't be used with binary classification", "validation-no-species-list-selected": "Please select a species list.", "validation-no-training-data-selected": "Please select your training data.", "validation-no-valid-batch-size": "Please enter a valid batch size.", "validation-no-valid-classifier-name": "Please enter a valid name for the classifier.", "validation-no-valid-epoch-number": "Please enter a valid number of epochs.", "validation-no-valid-frequency": "Please enter a valid frequency in", - "validation-no-valid-learning-rate": "Please enter a valid learning rate." + "validation-no-valid-learning-rate": "Please enter a valid learning rate.", + "validation-non-event-samples-required-in-binary-classification": "Non-event samples are required for binary classification", + "validation-only-repeat-upsampling-for-multi-label": "Only repeat-upsampling is available for multi-label training" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index 5bcb1599d..d773eb19d 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Liitä-tila ei koske irrotettua luokitinta.", "validation-max-confidence-lower-than-min-confidence": "Enimmäisluotettavuuden on oltava suurempi kuin vähimmäisluotettavuus", "validation-no-audio-directory-selected": "Äänihakemistoa ei ole valittu", + "validation-no-audio-files-in-training-folders": "Seuraavat koulutusdatakansiot eivät sisällä tuettuja äänitiedostoja: {0}", "validation-no-cache-file-selected": "Valitse välimuistitiedosto.", "validation-no-custom-classifier-selected": "Mukautettua luokittelijaa ei ole valittu.", "validation-no-directory-for-classifier-selected": "Valitse hakemisto luokittelijalle.", "validation-no-directory-selected": "Valitse hakemisto.", "validation-no-file-selected": "Valitse tiedosto.", + "validation-no-negative-samples-in-binary-classification": "Negatiivisia luokkia ei voi käyttää binääriluokittelussa", "validation-no-species-list-selected": "Valitse lajilista.", "validation-no-training-data-selected": "Valitse koulutusdatasi.", "validation-no-valid-batch-size": "Anna kelvollinen eräkoko.", "validation-no-valid-classifier-name": "Anna kelvollinen nimi luokittelijalle.", "validation-no-valid-epoch-number": "Anna kelvollinen aikakausien määrä.", "validation-no-valid-frequency": "Anna kelvollinen taajuus", - "validation-no-valid-learning-rate": "Anna kelvollinen oppimistahti." + "validation-no-valid-learning-rate": "Anna kelvollinen oppimistahti.", + "validation-non-event-samples-required-in-binary-classification": "Binääriluokittelu vaatii ei-tapahtumanäytteitä", + "validation-only-repeat-upsampling-for-multi-label": "Monileimakoulutuksessa on käytettävissä vain toista-ylösnäytteistys" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index 45e4d2b9b..2dbf5cd0b 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Le mode d'ajout ne s'appliquera pas au classificateur détaché.", "validation-max-confidence-lower-than-min-confidence": "La confiance maximale doit être supérieure à la confiance minimale", "validation-no-audio-directory-selected": "Aucun dossiers audio sélectionnés", + "validation-no-audio-files-in-training-folders": "Les dossiers de données d'entraînement suivants ne contiennent aucun fichier audio pris en charge : {0}", "validation-no-cache-file-selected": "Veuillez sélectionner un fichier cache.", "validation-no-custom-classifier-selected": "Aucun classificateur personnalisé n'a été sélectionné.", "validation-no-directory-for-classifier-selected": "Veuillez sélectionner un dossier pour le classificateur.", "validation-no-directory-selected": "Veuillez sélectionner un dossier", "validation-no-file-selected": "Sélectionnez un fichier", + "validation-no-negative-samples-in-binary-classification": "Les étiquettes négatives ne peuvent pas être utilisées avec la classification binaire", "validation-no-species-list-selected": "Veuillez sélectionner une liste d’espèces", "validation-no-training-data-selected": "Veuillez sélectionner des données d’entraînement", "validation-no-valid-batch-size": "Veuillez saisir une taille de lot valide.", "validation-no-valid-classifier-name": "Veuillez saisir un nom valide pour le classificateur.", "validation-no-valid-epoch-number": "Veuillez saisir un nombre valide d'époques.", "validation-no-valid-frequency": "Veuillez saisir une fréquence valide dans", - "validation-no-valid-learning-rate": "Veuillez saisir un taux d'apprentissage valide." + "validation-no-valid-learning-rate": "Veuillez saisir un taux d'apprentissage valide.", + "validation-non-event-samples-required-in-binary-classification": "Des échantillons non-événement sont requis pour la classification binaire", + "validation-only-repeat-upsampling-for-multi-label": "Seul le suréchantillonnage par répétition est disponible pour l'entraînement multi-étiquettes" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index e95fd24ef..91e640214 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Mode tambah tidak akan berlaku untuk pengklasifikasi terlepas.", "validation-max-confidence-lower-than-min-confidence": "Kepercayaan maksimum harus lebih besar dari kepercayaan minimum", "validation-no-audio-directory-selected": "Tidak ada audio direktori yang dipilih", + "validation-no-audio-files-in-training-folders": "Folder data pelatihan berikut tidak berisi berkas audio yang didukung: {0}", "validation-no-cache-file-selected": "Silakan pilih berkas cache.", "validation-no-custom-classifier-selected": "Tidak ada klasifikator kustom yang dipilih.", "validation-no-directory-for-classifier-selected": "Silakan pilih direktori untuk klasifikator.", "validation-no-directory-selected": "Silakan pilih direktori.", "validation-no-file-selected": "Silakan pilih file.", + "validation-no-negative-samples-in-binary-classification": "Label negatif tidak dapat digunakan dengan klasifikasi biner", "validation-no-species-list-selected": "Silakan pilih daftar spesies.", "validation-no-training-data-selected": "Silakan pilih data pelatihan Anda.", "validation-no-valid-batch-size": "Silakan masukkan ukuran batch yang valid.", "validation-no-valid-classifier-name": "Silakan masukkan nama yang valid untuk klasifikator.", "validation-no-valid-epoch-number": "Silakan masukkan jumlah epochs yang valid.", "validation-no-valid-frequency": "Silakan masukkan frekuensi yang valid dalam", - "validation-no-valid-learning-rate": "Silakan masukkan laju pembelajaran yang valid." + "validation-no-valid-learning-rate": "Silakan masukkan laju pembelajaran yang valid.", + "validation-non-event-samples-required-in-binary-classification": "Sampel non-event diperlukan untuk klasifikasi biner", + "validation-only-repeat-upsampling-for-multi-label": "Hanya peningkatan sampel mode ulang yang tersedia untuk pelatihan multi-label" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index 3d48d0c20..7d56ed5f5 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "O modo de acréscimo não será aplicado ao classificador desanexado.", "validation-max-confidence-lower-than-min-confidence": "A confiança máxima deve ser maior que a confiança mínima", "validation-no-audio-directory-selected": "Nenhum diretório de áudios selecionado", + "validation-no-audio-files-in-training-folders": "As seguintes pastas de dados de treinamento não contêm arquivos de áudio compatíveis: {0}", "validation-no-cache-file-selected": "Selecione um arquivo de cache.", "validation-no-custom-classifier-selected": "Nenhum classificador customizado selecionado.", "validation-no-directory-for-classifier-selected": "Selecione um diretório para o classificador.", "validation-no-directory-selected": "Selecione um diretório. ", "validation-no-file-selected": "Selecione um arquivo.", + "validation-no-negative-samples-in-binary-classification": "Rótulos negativos não podem ser usados com classificação binária", "validation-no-species-list-selected": "Selecione uma lista de espécies.", "validation-no-training-data-selected": "Selecione seus dados de treinamento.", "validation-no-valid-batch-size": "Adicionar um tamanho de lote (batch) válido.", "validation-no-valid-classifier-name": "Adicionar um nome válido para o classificador.", "validation-no-valid-epoch-number": "Adicionar um número de épocas (epochs) válido.", "validation-no-valid-frequency": "Adicionar uma frequência válida em", - "validation-no-valid-learning-rate": "Adicionar uma taxa de amostragem válida." + "validation-no-valid-learning-rate": "Adicionar uma taxa de amostragem válida.", + "validation-non-event-samples-required-in-binary-classification": "Amostras de não-evento são necessárias para a classificação binária", + "validation-only-repeat-upsampling-for-multi-label": "Apenas o upsampling de repetição está disponível para treinamento multirrótulo" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index cf73464da..87b837f2c 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Режим добавления не применяется к отсоединённому классификатору.", "validation-max-confidence-lower-than-min-confidence": "Максимальная достоверность должна быть больше минимальной достоверности", "validation-no-audio-directory-selected": "Каталог аудио не выбран", + "validation-no-audio-files-in-training-folders": "Следующие папки с обучающими данными не содержат поддерживаемых аудиофайлов: {0}", "validation-no-cache-file-selected": "Пожалуйста, выберите файл кэша.", "validation-no-custom-classifier-selected": "Пользовательский классификатор не выбран.", "validation-no-directory-for-classifier-selected": "Пожалуйста, выберите каталог для классификатора.", "validation-no-directory-selected": "Пожалуйста, выберите каталог.", "validation-no-file-selected": "Пожалуйста, выберите файл.", + "validation-no-negative-samples-in-binary-classification": "Отрицательные метки нельзя использовать при бинарной классификации", "validation-no-species-list-selected": "Пожалуйста, выберите список видов.", "validation-no-training-data-selected": "Выберите данные обучения.", "validation-no-valid-batch-size": "Пожалуйста, введите допустимый размер пакета.", "validation-no-valid-classifier-name": "Пожалуйста, введите имя для классификатора.", "validation-no-valid-epoch-number": "Пожалуйста, введите допустимое количество периодов.", "validation-no-valid-frequency": "Введите допустимую частоту в", - "validation-no-valid-learning-rate": "Пожалуйста, введите допустимый коэффициент обучения." + "validation-no-valid-learning-rate": "Пожалуйста, введите допустимый коэффициент обучения.", + "validation-non-event-samples-required-in-binary-classification": "Для бинарной классификации требуются образцы без событий", + "validation-only-repeat-upsampling-for-multi-label": "Для обучения с несколькими метками доступен только режим повышающей дискретизации «повтор»" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index 6273e5e56..820bd1cba 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "Tilläggsläget gäller inte för den fristående klassificeraren.", "validation-max-confidence-lower-than-min-confidence": "Maximal konfidens måste vara större än minimal konfidens", "validation-no-audio-directory-selected": "Ingen ljudkatalog vald", + "validation-no-audio-files-in-training-folders": "Följande mappar med träningsdata innehåller inga ljudfiler som stöds: {0}", "validation-no-cache-file-selected": "Vänligen välj en cachefil.", "validation-no-custom-classifier-selected": "Ingen anpassad klassificerare vald.", "validation-no-directory-for-classifier-selected": "Välj en katalog för klassificeraren.", "validation-no-directory-selected": "Välj en katalog.", "validation-no-file-selected": "Välj en fil.", + "validation-no-negative-samples-in-binary-classification": "Negativa etiketter kan inte användas med binär klassificering", "validation-no-species-list-selected": "Välj en artlista.", "validation-no-training-data-selected": "Välj dina träningsdata.", "validation-no-valid-batch-size": "Ange en giltig batchstorlek.", "validation-no-valid-classifier-name": "Ange ett giltigt namn för klassificeraren.", "validation-no-valid-epoch-number": "Ange ett giltigt antal epoker.", "validation-no-valid-frequency": "Ange en giltig frekvens i", - "validation-no-valid-learning-rate": "Ange en giltig inlärningshastighet." + "validation-no-valid-learning-rate": "Ange en giltig inlärningshastighet.", + "validation-non-event-samples-required-in-binary-classification": "Prover utan händelser krävs för binär klassificering", + "validation-only-repeat-upsampling-for-multi-label": "Endast upsampling med upprepning är tillgänglig för träning med flera etiketter" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index 544b00408..2d1dc8e3d 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "chel mIw DetlhHa' ghovwI'vaD lInglaHbe'.", "validation-max-confidence-lower-than-min-confidence": "nIvbogh vIt patlh tIn law' minimum vIt patlh tIn puS", "validation-no-audio-directory-selected": "wav Daq tu'Ha'.", + "validation-no-audio-files-in-training-folders": "qeq De' pa'meyvamDaq wab De' tu'lu'be': {0}", "validation-no-cache-file-selected": "polmeH teywI' yIwIv.", "validation-no-custom-classifier-selected": "chu' tu'law' yIwIvHa'.", "validation-no-directory-for-classifier-selected": "tu'law' Daq yIwIv.", "validation-no-directory-selected": "Daq yIwIv.", "validation-no-file-selected": "wav yIwIv.", + "validation-no-negative-samples-in-binary-classification": "cha' buv qeqvaD qechmey Qup lo'laHbe'", "validation-no-species-list-selected": "Seghmey wav yIwIv.", "validation-no-training-data-selected": "qeq De' yIwIv.", "validation-no-valid-batch-size": "batch chu' yIchoH.", "validation-no-valid-classifier-name": "tu'law' pong yIchoH.", "validation-no-valid-epoch-number": "epQochmey yIchoH.", "validation-no-valid-frequency": "qeq 'eS patlh yIchoH", - "validation-no-valid-learning-rate": "qeq patlh yIchoH." + "validation-no-valid-learning-rate": "qeq patlh yIchoH.", + "validation-non-event-samples-required-in-binary-classification": "cha' buv qeqvaD wanI'Ha' De' poQlu'", + "validation-only-repeat-upsampling-for-multi-label": "multi-label qeqvaD qechmey ghun neH lo'laH" } \ No newline at end of file diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index c7d6e81ac..ad9641003 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -432,16 +432,20 @@ "training-tab-warning-detached-classifier-append": "附加模式不適用於分離的分類器。", "validation-max-confidence-lower-than-min-confidence": "最大可信度必須大於最小可信度", "validation-no-audio-directory-selected": "請提供包含音檔的資料夾", + "validation-no-audio-files-in-training-folders": "下列訓練資料夾不含任何支援的音訊檔案:{0}", "validation-no-cache-file-selected": "請選擇快取檔案。", "validation-no-custom-classifier-selected": "尚未選擇客製化分類器", "validation-no-directory-for-classifier-selected": "請選擇分類器路徑", "validation-no-directory-selected": "請選擇資料夾", "validation-no-file-selected": "請選擇檔案", + "validation-no-negative-samples-in-binary-classification": "二元分類無法使用負標籤", "validation-no-species-list-selected": "請選擇物種清單", "validation-no-training-data-selected": "請選擇訓練用資料", "validation-no-valid-batch-size": "請輸入有效的批次量(在同一時間分析的音檔數量)", "validation-no-valid-classifier-name": "請輸入有效的分類器名稱", "validation-no-valid-epoch-number": "請輸入有效的訓練期(eopch)數量", "validation-no-valid-frequency": "請輸入有效的頻率", - "validation-no-valid-learning-rate": "請輸入有效的學習速率" + "validation-no-valid-learning-rate": "請輸入有效的學習速率", + "validation-non-event-samples-required-in-binary-classification": "二元分類需要非事件樣本", + "validation-only-repeat-upsampling-for-multi-label": "多標籤訓練僅支援重覆上取樣" } \ No newline at end of file diff --git a/birdnet_analyzer/model.py b/birdnet_analyzer/model.py index d73413f5a..525ec99a4 100644 --- a/birdnet_analyzer/model.py +++ b/birdnet_analyzer/model.py @@ -170,8 +170,8 @@ def random_split(x, y, rng: Generator, val_ratio=0.2): train_indices.append(class_train_indices) val_indices.append(class_val_indices) - # Negative samples are not class-specific in single-label training. Appending - # them in the loop above duplicates every negative sample once per class. + # Add every row with a negative label to the training set exactly once; a + # per-class scan would duplicate rows that are negative for several classes. negative_indices = np.unique(np.where(y == -1)[0]) train_indices.append(negative_indices) @@ -334,7 +334,7 @@ def upsampling( min_samples = ( int(max(y.sum(axis=0), len(y) - y.sum(axis=0)) * ratio) if is_binary - else int(np.max(y.sum(axis=0)) * ratio) + else int(np.max((y == 1).sum(axis=0)) * ratio) ) x_temp = [] y_temp = [] diff --git a/birdnet_analyzer/train/utils.py b/birdnet_analyzer/train/utils.py index 2c547ac61..22938e06e 100644 --- a/birdnet_analyzer/train/utils.py +++ b/birdnet_analyzer/train/utils.py @@ -175,9 +175,12 @@ def _check_input_folders(audio_input: str, train_folders: list[str]): empty_folders.append(folder) if empty_folders: + folder_list = ", ".join(sorted(empty_folders)) raise ValueError( "The following training data folders do not contain any supported audio " - f"files: {', '.join(empty_folders)}" + f"files: {folder_list}", + "validation-no-audio-files-in-training-folders", + folder_list, ) @@ -267,7 +270,7 @@ def _load_training_data( if is_multi_label and upsampling_ratio > 0 and upsampling_mode != "repeat": raise Exception( - "Only repeat-upsampling ist available for multi-label", + "Only repeat-upsampling is available for multi-label", "validation-only-repeat-upsampling-for-multi-label", ) diff --git a/tests/train/test_train_loading.py b/tests/train/test_train_loading.py index 5c6b1d9bb..4bc136f1b 100644 --- a/tests/train/test_train_loading.py +++ b/tests/train/test_train_loading.py @@ -95,6 +95,7 @@ def test_load_training_data_lists_empty_folders_before_loading_model(tmp_path): _load_training_data(str(tmp_path)) assert "robin" not in str(error.value) + assert error.value.args[1] == "validation-no-audio-files-in-training-folders" mock_load.assert_not_called()