Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions birdnet_analyzer/evaluation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ class EvaluationResult(NamedTuple):
Besides the metrics and the tensors the assessment ran on, it carries the context
a caller needs to explain the numbers: which recordings had predictions but no
annotations, and which classes were empty (and therefore excluded from any
aggregate score).
aggregate score). ``sample_data`` is the per-window matrix the metrics were derived
from -- the same table the GUI offers as the "data table" download.
"""

metrics_df: "pd.DataFrame"
Expand All @@ -43,6 +44,7 @@ class EvaluationResult(NamedTuple):
classes: tuple[str, ...]
unmatched_recordings: tuple[str, ...]
empty_classes: tuple[str, ...]
sample_data: "pd.DataFrame"


def process_data(
Expand All @@ -56,7 +58,7 @@ def process_data(
columns_predictions: dict[str, str] | None = None,
selected_classes: Sequence[str] | None = None,
selected_recordings: list[str] | None = None,
metrics_list: tuple[str, ...] = ("accuracy", "precision", "recall"),
metrics_list: tuple[str, ...] = ("auroc", "precision", "recall", "f1", "ap"),
threshold: float = 0.1,
class_wise: bool = False,
averaging: Literal["macro", "micro", "weighted"] = "macro",
Expand Down Expand Up @@ -176,9 +178,33 @@ def process_data(
classes=classes,
unmatched_recordings=tuple(sorted(processor.unmatched_prediction_files)),
empty_classes=pa.empty_classes(labels),
sample_data=processor.get_sample_data(),
)


# The metrics the PerformanceAssessor understands; also the CLI's allowed choices.
VALID_METRICS = ("accuracy", "recall", "precision", "f1", "ap", "auroc")


def _threshold_arg(value: str) -> float:
"""argparse type for ``--threshold``: a float strictly between 0 and 1.

Fails at the parser with a clear message instead of deep inside the
PerformanceAssessor, which requires ``0 < threshold < 1``.
"""
try:
number = float(value)
except ValueError:
raise argparse.ArgumentTypeError(
f"threshold must be a number between 0 and 1 (exclusive), got {value!r}"
) from None
if not 0 < number < 1:
raise argparse.ArgumentTypeError(
f"threshold must be between 0 and 1 (exclusive), got {value}"
)
return number


def main():
"""
Entry point for the script. Parses command-line arguments and orchestrates the
Expand Down Expand Up @@ -232,11 +258,16 @@ def main():
parser.add_argument(
"--metrics",
nargs="+",
default=["accuracy", "precision", "recall"],
help="List of metrics",
choices=VALID_METRICS,
default=["auroc", "precision", "recall", "f1", "ap"],
help="List of metrics (accuracy is excluded by default; in this sample-based "
"multilabel setting it is dominated by true negatives and misleadingly high)",
)
parser.add_argument(
"--threshold", type=float, default=0.1, help="Threshold value (0-1)"
"--threshold",
type=_threshold_arg,
default=0.1,
help="Threshold value, strictly between 0 and 1",
)
parser.add_argument(
"--class_wise", action="store_true", help="Calculate class-wise metrics"
Expand Down Expand Up @@ -301,9 +332,18 @@ def main():
", ".join(result.empty_classes),
)

# Create output directory if needed
if args.output_dir and not os.path.exists(args.output_dir):
os.makedirs(args.output_dir)
# Write the result tables (and later the plots) when an output directory is given.
# These mirror the GUI's "results table" and "data table" downloads.
if args.output_dir:
os.makedirs(args.output_dir, exist_ok=True)

results_table_path = os.path.join(args.output_dir, "results_table.csv")
result.metrics_df.to_csv(results_table_path, index=True)
logger.info("Saved results table to %s", results_table_path)

data_table_path = os.path.join(args.output_dir, "data_table.csv")
result.sample_data.to_csv(data_table_path, index=False)
logger.info("Saved data table to %s", data_table_path)

# Generate plots if specified
if args.plot_metrics:
Expand Down
5 changes: 3 additions & 2 deletions birdnet_analyzer/gui/multi_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def _output_type_map():
loc.localize("multi-tab-output-type-raven-label"): "table",
loc.localize("multi-tab-output-type-audacity-label"): "audacity",
loc.localize("multi-tab-output-type-csv-label"): "csv",
loc.localize("multi-tab-output-type-parquet-label"): "parquet",
loc.localize("multi-tab-output-type-kaleidoscope-label"): "kaleidoscope",
}

Expand Down Expand Up @@ -249,7 +250,7 @@ def select_directory_wrapper():
gr.CheckboxGroup,
choices=list(_additional_columns_map().items()),
value=[],
visible="csv" in output_type_radio.value,
visible=bool({"csv", "parquet"} & set(output_type_radio.value)),
label=loc.localize("multi-tab-additional-columns-checkbox-label"),
info=loc.localize("multi-tab-additional-columns-checkbox-info"),
)
Expand Down Expand Up @@ -297,7 +298,7 @@ def select_directory_wrapper():
]

def show_additional_columns(values):
return gr.update(visible="csv" in values)
return gr.update(visible=bool({"csv", "parquet"} & set(values)))

start_batch_analysis_btn.click(
run_batch_analysis, inputs=inputs, outputs=result_grid
Expand Down
75 changes: 61 additions & 14 deletions birdnet_analyzer/gui/train.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def start_training(
upsampling_mode,
model_formats,
audio_speed,
threads,
progress=gr.Progress(),
):
"""Starts the training of a custom classifier.
Expand Down Expand Up @@ -106,7 +107,8 @@ def start_training(
upsampling_mode: Mode for upsampling (repeat, mean, smote).
model_formats: Formats to save the trained model (tflite, raven, detached).
audio_speed: Speed factor for audio playback.
save_detached_classifier: Whether to save the detached classifier.
threads: Number of parallel CPU threads for loading and decoding the training
audio. Ignored when training from a cache file.
Returns:
Comment thread
Copilot marked this conversation as resolved.
Returns a matplotlib.pyplot figure.
"""
Expand Down Expand Up @@ -212,7 +214,9 @@ def trial_progression(trial):
else None,
dropout=max(0.0, min(1.0, float(dropout))),
overlap=max(0.0, min(2.9, float(crop_overlap))),
threads=max(1, multiprocessing.cpu_count()),
threads=int(threads)
if threads and int(threads) > 0
else max(1, multiprocessing.cpu_count()),
on_epoch_end=epoch_progression,
on_trial_result=trial_progression,
on_data_load_end=data_load_progression,
Expand Down Expand Up @@ -534,6 +538,7 @@ def on_cache_mode_change(value):
gr.update(interactive=value != "load"),
gr.update(interactive=value != "load"),
gr.update(interactive=value != "load"),
gr.update(interactive=value != "load"),
)

with (
Expand Down Expand Up @@ -620,6 +625,18 @@ def on_cache_mode_change(value):
interactive=crops_segments and not uses_cache_file,
)

threads_number = state.persist(
"threads_number",
gr.Number,
value=multiprocessing.cpu_count(),
minimum=1,
step=1,
precision=0,
interactive=not uses_cache_file,
label=loc.localize("training-tab-threads-number-label"),
info=loc.localize("training-tab-threads-number-info"),
)

def on_crop_select(new_crop_mode):
# Make overlap slider visible for both "segments" and "smart" crop modes
return gr.update(
Expand All @@ -644,6 +661,7 @@ def on_crop_select(new_crop_mode):
audio_speed_slider,
crop_mode,
crop_overlap,
threads_number,
],
show_progress="hidden",
)
Expand Down Expand Up @@ -684,8 +702,13 @@ def on_crop_select(new_crop_mode):
info=loc.localize("training-tab-autotune-repeats-number-info"),
)

# The custom-parameters accordion stays visible while autotuning; every field
# the tuner searches is disabled instead (its value is only a seed and gets
# overridden), so users can still see the values but not waste time tuning them
# by hand. "epochs" is not autotuned and stays editable -- it applies to every
# trial.
with (
gr.Group(visible=not autotunes) as custom_params,
gr.Group(),
gr.Accordion(
open=False,
label=loc.localize("training-tab-custom-params-accordion-label"),
Expand All @@ -707,6 +730,7 @@ def on_crop_select(new_crop_mode):
value=32,
minimum=1,
step=8,
interactive=not autotunes,
label=loc.localize("training-tab-batchsize-number-label"),
info=loc.localize("training-tab-batchsize-number-info"),
)
Expand All @@ -716,6 +740,7 @@ def on_crop_select(new_crop_mode):
value=0.0001,
minimum=0.0001,
step=0.0001,
interactive=not autotunes,
label=loc.localize("training-tab-learningrate-number-label"),
info=loc.localize("training-tab-learningrate-number-info"),
)
Expand All @@ -727,6 +752,7 @@ def on_crop_select(new_crop_mode):
value=0,
minimum=0,
step=64,
interactive=not autotunes,
label=loc.localize("training-tab-hiddenunits-number-label"),
info=loc.localize("training-tab-hiddenunits-number-info"),
)
Expand All @@ -737,13 +763,15 @@ def on_crop_select(new_crop_mode):
minimum=0.0,
maximum=0.9,
step=0.1,
interactive=not autotunes,
label=loc.localize("training-tab-dropout-number-label"),
info=loc.localize("training-tab-dropout-number-info"),
)
use_label_smoothing = state.persist(
"use_label_smoothing_checkbox",
gr.Checkbox,
value=False,
interactive=not autotunes,
label=loc.localize(
"training-tab-use-labelsmoothing-checkbox-label"
),
Expand Down Expand Up @@ -774,6 +802,7 @@ def on_crop_select(new_crop_mode):
),
],
value="repeat",
interactive=not autotunes,
label=loc.localize("training-tab-upsampling-radio-label"),
info=loc.localize("training-tab-upsampling-radio-info"),
)
Expand All @@ -784,6 +813,7 @@ def on_crop_select(new_crop_mode):
maximum=1.0,
value=0.0,
step=0.05,
interactive=not autotunes,
label=loc.localize("training-tab-upsampling-ratio-slider-label"),
info=loc.localize("training-tab-upsampling-ratio-slider-info"),
)
Expand All @@ -793,6 +823,7 @@ def on_crop_select(new_crop_mode):
"use_mixup_checkbox",
gr.Checkbox,
value=False,
interactive=not autotunes,
label=loc.localize("training-tab-use-mixup-checkbox-label"),
info=loc.localize("training-tab-use-mixup-checkbox-info"),
show_label=True,
Expand All @@ -801,24 +832,23 @@ def on_crop_select(new_crop_mode):
"use_focal_loss_checkbox",
gr.Checkbox,
value=False,
interactive=not autotunes,
label=loc.localize("training-tab-use-focal-loss-checkbox-label"),
info=loc.localize("training-tab-use-focal-loss-checkbox-info"),
show_label=True,
)

with gr.Row(
visible=bool(use_focal_loss.value) and not autotunes
) as focal_loss_params:
with gr.Row(visible=bool(use_focal_loss.value)) as focal_loss_params:
focal_loss_gamma = state.persist(
"focal_loss_gamma_slider",
gr.Slider,
minimum=0.5,
maximum=5.0,
value=2.0,
step=0.1,
interactive=not autotunes,
label=loc.localize("training-tab-focal-loss-gamma-slider-label"),
info=loc.localize("training-tab-focal-loss-gamma-slider-info"),
interactive=True,
)
focal_loss_alpha = state.persist(
"focal_loss_alpha_slider",
Expand All @@ -827,9 +857,9 @@ def on_crop_select(new_crop_mode):
maximum=0.9,
value=0.25,
step=0.05,
interactive=not autotunes,
label=loc.localize("training-tab-focal-loss-alpha-slider-label"),
info=loc.localize("training-tab-focal-loss-alpha-slider-info"),
interactive=True,
)

def on_focal_loss_change(value):
Expand All @@ -842,17 +872,33 @@ def on_focal_loss_change(value):
show_progress="hidden",
)

# Every hyperparameter the tuner searches. When autotuning is on these are
# disabled (their values are only seeds and get overridden); "epochs" is not
# tuned and stays editable.
autotuned_params = [
batch_size_number,
learning_rate_number,
hidden_units_number,
dropout_number,
use_label_smoothing,
upsampling_mode,
upsampling_ratio,
use_mixup,
use_focal_loss,
focal_loss_gamma,
focal_loss_alpha,
]

def on_autotune_change(value):
return (
gr.update(visible=not value),
gr.update(visible=value),
gr.update(visible=not value and use_focal_loss.value),
)
return [
gr.update(visible=value), # autotune_params
*[gr.update(interactive=not value) for _ in autotuned_params],
]

autotune_cb.change(
on_autotune_change,
inputs=autotune_cb,
outputs=[custom_params, autotune_params, focal_loss_params],
outputs=[autotune_params, *autotuned_params],
show_progress="hidden",
)

Expand Down Expand Up @@ -966,6 +1012,7 @@ def train_and_show_metrics(*args):
upsampling_mode,
output_formats,
audio_speed_slider,
threads_number,
],
outputs=[train_history_plot, metrics_table],
)
Expand Down
5 changes: 4 additions & 1 deletion birdnet_analyzer/lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"embeddings-tab-select-input-directory-textbox-placeholder": "Kein Eingabeverzeichnis ausgewählt",
"embeddings-tab-start-button-label": "Embeddings extrahieren",
"embeddings-tab-title": "Embeddings",
"eval-tab-accuracy-checkbox-info": "Accuracy misst den Prozentsatz der richtigen Vorhersagen des Modells.",
"eval-tab-accuracy-checkbox-info": "Accuracy ist der Anteil korrekter Vorhersagen. Da die meisten Fenster keinen Ruf enthalten, dominieren echte Negative und die Accuracy bleibt irreführend hoch – daher ist sie standardmäßig deaktiviert.",
"eval-tab-annotation-col-accordion-label": "Annotationsspalten",
"eval-tab-annotation-selection-button-label": "Verzeichnis mit Annotationen auswählen",
"eval-tab-annotation-selection-textbox-placeholder": "Kein Annotationsverzeichnis ausgewählt",
Expand Down Expand Up @@ -191,6 +191,7 @@
"multi-tab-output-type-audacity-label": "Audacity",
"multi-tab-output-type-csv-label": "CSV",
"multi-tab-output-type-kaleidoscope-label": "Kaleidoscope",
"multi-tab-output-type-parquet-label": "Parquet",
"multi-tab-output-type-raven-label": "Raven-Auswahltabelle",
"multi-tab-result-dataframe-column-invalid-file-header": "Ungültige Audiodateien",
"multi-tab-result-dataframe-column-success-header": "Alle Dateien wurden analysiert!",
Expand Down Expand Up @@ -423,6 +424,8 @@
"training-tab-start-training-button-label": "Training starten",
"training-tab-test-data-selection-button-label": "Testdaten auswählen (optional)",
"training-tab-test-data-selection-textbox-placeholder": "Kein Testdatenverzeichnis ausgewählt",
"training-tab-threads-number-info": "Anzahl paralleler CPU-Threads zum Laden und Dekodieren der Trainingsaudios. Mehr Threads können das Laden beschleunigen, brauchen aber mehr CPU und Speicher. Wird beim Training aus einer Cache-Datei ignoriert.",
"training-tab-threads-number-label": "Threads zum Laden der Daten",
"training-tab-title": "Trainieren",
"training-tab-upsampling-radio-info": "Balancieren Sie die Trainingsdaten durch Upsampling von Minderheitenklassen aus.",
"training-tab-upsampling-radio-label": "Upsampling-Modus",
Expand Down
Loading
Loading